Overlays & UI State
<Modal>, <Drawer>,
<Popover>, <Tooltip>,
<Menu>, <ToastHost> and
ui.confirm() are built in - no import, no scaffolding, the same
deal as <show> and <for>.
They ship behavior: layering, focus, dismissal, anchoring, animation.
They are not buttons or cards, they have no variant or size props, and there is no theme system. That part is CSS's job.
Modal
A modal is two things: something that opens it, and
<Modal>.
The surface, backdrop, ESC key, focus trap, scroll lock and animation already exist.
<script>
function openTerms() {
ui.modal.open('docs-terms')
}
</script>
<button class="btn" onclick="openTerms()">View terms</button>
<Modal name="docs-terms" label="Terms of service">
<h3>Terms</h3>
<p>The surface, the backdrop, ESC, the focus trap and the animation are all
already here. Only this content is yours.</p>
<button class="btn" onclick="ui.modal.close('docs-terms')">Close</button>
</Modal>
It is a native <dialog> opened with
showModal(), so it lives in the browser's top layer: no z-index
to fight, and the focus trap and ESC handling come from the browser rather
than from us.
Two ways to open one
For a modal one component owns, bind a local boolean and skip names entirely.
Escape and a backdrop click both close it, and the binding writes
false back:
<script>
let showSettings = false
</script>
<button class="btn" onclick="showSettings = true">Settings</button>
<Modal bind:open={showSettings} label="Settings">
<h3>Settings</h3>
<p>No name, no registry - just a local boolean.</p>
<p>Press Escape or click the backdrop to close - the binding writes false back.</p>
</Modal>
Use name and ui.modal.open('x') when something
else opens it: another component, a router guard, a keyboard
shortcut.
The two are the same state underneath - a named
<Modal> renders ui.modal rather than keeping a
second copy of it - so they can never disagree.
Modal props
| Prop | Default | What it does |
|---|---|---|
name |
generated | Registry name. Needed only if something else opens it. |
bind:open |
- | Two-way bind to a local boolean. |
open |
- | One-way expression. Read only. |
animate |
true | animate="false" opens instantly. |
headless |
false | Drop the surface card, keep the behavior. |
class |
- | Extra classes on the <dialog>. |
label / labelledby |
- | aria-label / aria-labelledby. |
onClose |
- | Expression to run after it closes. |
closeOnEscape, closeOnBackdrop,
lockScroll, closeOnNavigate,
history, trapFocus
|
see below |
Behavior declared on the tag. It applies to a bare
ui.modal.open(name) too.
|
Slot content is mounted as its own tree. Markup and
globals - ui.*, route, store - work
fine. Expressions that read the enclosing component's
lets do not resolve, the same limitation
<for> and <show> children have. Put
what the overlay needs in a store, in
ui.modal.data(name), or in a component of its own. This holds
for every overlay on this page.
Drawer
A drawer is a modal pinned to an edge: same element, different skin.
side picks the edge and the panel slides in from it.
<script>
function openNav() {
ui.drawer.open('docs-nav')
}
</script>
<button class="btn" onclick="openNav()">Open navigation</button>
<Drawer name="docs-nav" side="left" label="Navigation">
<h3>Navigation</h3>
<p>Pinned to the left edge, sliding in from it. ESC, the backdrop and the
focus trap are already here.</p>
<button class="btn" onclick="ui.drawer.close('docs-nav')">Close</button>
</Drawer>
All four edges are the same component, and only one drawer is open at a time, so opening one closes the last.
These four declare history="false" so that flipping straight
from one to another does not fight the back button:
<button class="btn" onclick="ui.drawer.open('docs-side-left')">Left</button>
<button class="btn" onclick="ui.drawer.open('docs-side-right')">Right</button>
<button class="btn" onclick="ui.drawer.open('docs-side-top')">Top</button>
<button class="btn" onclick="ui.drawer.open('docs-side-bottom')">Bottom</button>
<Drawer name="docs-side-left" side="left" history="false" label="Left drawer">
<p>side="left"</p>
<button class="btn" onclick="ui.drawer.close('docs-side-left')">Close</button>
</Drawer>
<Drawer name="docs-side-right" side="right" history="false" label="Right drawer">
<p>side="right"</p>
<button class="btn" onclick="ui.drawer.close('docs-side-right')">Close</button>
</Drawer>
<Drawer name="docs-side-top" side="top" history="false" label="Top drawer">
<p>side="top"</p>
<button class="btn" onclick="ui.drawer.close('docs-side-top')">Close</button>
</Drawer>
<Drawer name="docs-side-bottom" side="bottom" history="false" label="Bottom drawer">
<p>side="bottom"</p>
<button class="btn" onclick="ui.drawer.close('docs-side-bottom')">Close</button>
</Drawer>
<Drawer> takes every <Modal> prop plus
side: left (default), right,
top, bottom.
Its width or height comes from --jaf-overlay-drawer-size.
Popover
An anchored panel on the browser's Popover API. It is in the top layer, and light dismiss - outside click, ESC - is the browser's, with no listener of ours involved.
<button id="docs-acct-trigger" class="btn" onclick="ui.popover.toggle('docs-acct', this)">
Account
</button>
<Popover name="docs-acct" for="docs-acct-trigger" placement="bottom" label="Account">
<p><strong>Signed in</strong></p>
<p>Click anywhere outside, or press ESC, to dismiss this.</p>
<button class="btn" onclick="ui.popover.close('docs-acct')">Sign out</button>
</Popover>
The trigger is whatever for points at, or the element immediately
before the tag if there is no for.
An imperative ui.popover.open(name, element) beats both, because
the caller knows which of several triggers was used.
| Prop | Default | What it does |
|---|---|---|
name |
generated | Registry name. Needed only if something else opens it. |
for |
previous element | Trigger id or CSS selector. |
placement |
bottom |
top, bottom, left,
right. Flips when it does not fit.
|
bind:open / open |
- | Two-way bind, or a one-way expression. |
animate, headless, class,
label, onClose
|
as Modal | Same meaning as on <Modal>. |
Tooltip
The same machinery on hover and focus - tabbing to the button shows
it, which is the whole reason it listens for focus. It carries
role="tooltip", the trigger points at it with
aria-describedby, and it never takes or traps focus.
<button id="docs-tip-save" class="btn">Save</button>
<Tooltip for="docs-tip-save" placement="top">
Saves your work. Tab to the button to see this from the keyboard.
</Tooltip>
Placement defaults to top.
Delays come from
--jaf-overlay-tooltip-delay and -hide-delay;
delay and hideDelay override them per instance, in
milliseconds.
Menu
<Menu> is behavior only, and deliberately so. You get
anchoring, dismissal and the full keyboard: arrows, Home/End, typeahead, Enter
and Space to activate, ESC and Tab to close, a roving tabindex so the menu is
one tab stop, and focus returned to the trigger.
There is no item API, no icons, no nesting and no styling past the shared overlay shell.
<button id="docs-menu-trigger" class="btn" onclick="ui.popover.toggle('docs-menu', this)">
Actions
</button>
<Menu name="docs-menu" for="docs-menu-trigger" label="Row actions">
<button role="menuitem" onclick="ui.toast.show('Renamed')">Rename</button>
<button role="menuitem" onclick="ui.toast.show('Duplicated')">Duplicate</button>
<button role="menuitem" onclick="ui.toast.show('Archived')">Archive</button>
<button role="menuitem" onclick="ui.toast.show('Deleted')">Delete</button>
</Menu>
An item is any element carrying role="menuitem". If none do, the
direct children become the items and get the role.
Disabled and hidden items are skipped by the keyboard, and the trigger gets
aria-haspopup and aria-expanded.
The lowercase tag stays yours. <menu>
is a standard HTML element, so <Menu> is PascalCase
only. Every other overlay takes both spellings.
Toasts
ui.toast.show() renders.
There is nothing to set up: if a toast fires and no host is mounted, JAF mounts one at that position.
<script>
function save() {
ui.toast.show('Saved!', { type: 'success' })
}
function fail() {
ui.toast.show('Could not save', { type: 'error', duration: 0 })
}
</script>
<button class="btn" onclick="save()">Save</button>
<button class="btn" onclick="fail()">Fail</button>
Declare <ToastHost /> in your layout only when you want to
choose the corner. It takes that position over from the auto-host, so nothing
renders twice:
<!-- pages/layout.html -->
<slot />
<ToastHost position="bottom-right" />
position is one of top-left,
top-center, top-right (default),
bottom-left, bottom-center,
bottom-right.
Mount one host per corner you use.
| Option | Values | Default |
|---|---|---|
type |
'info', 'success', 'warning', 'error' | 'info' |
duration |
ms, 0 for manual dismiss | 5000 |
position |
as above | 'top-right' |
action |
{ label, onClick } |
- |
show() returns the toast id, and
ui.toast.dismiss(id), ui.toast.dismissAll() and
ui.toast.list complete the API.
To render the list yourself, turn the auto-host off so you do not get both:
ui.configure({ toast: { autoHost: false } })
Promise dialogs
ui.confirm(), ui.alert() and
ui.prompt() render a complete accessible dialog and resolve when
it closes.
Zero markup, zero names, zero conditionals.
<script>
let result = 'nothing yet'
async function deleteNote() {
const ok = await ui.confirm({
title: 'Delete note?',
message: 'This cannot be undone.',
confirmText: 'Delete',
danger: true,
})
result = ok ? 'deleted' : 'kept'
}
</script>
<button class="btn" onclick="deleteNote()">Delete note</button>
<p>Result: {result}</p>
Result: nothing yet
| Call | Resolves to |
|---|---|
ui.confirm(message | options) |
boolean |
ui.alert(message | options) |
void |
ui.prompt(message | options) |
string | null |
Options are message, title,
confirmText, cancelText, danger,
animate and closeOnBackdrop, plus
defaultValue and placeholder for
ui.prompt.
A bare string means { message }.
There is one dialog element and it never stacks: a call made while another is open joins a FIFO queue.
Focus returns to whatever opened each one.
Theming
Every value is a --jaf-overlay-* custom property.
One :root block reskins every overlay, and there is nothing else
to override:
:root {
--jaf-overlay-surface: var(--bg);
--jaf-overlay-text: var(--text);
--jaf-overlay-border: var(--border);
--jaf-overlay-radius: 10px;
--jaf-overlay-accent: var(--accent);
}
| Group | Properties, after the --jaf-overlay- prefix |
|---|---|
| Surface |
backdrop, surface, text,
muted, border, shadow
|
| Geometry |
radius, padding, gap,
width, inset, z
|
| Motion |
duration, ease, scale-from,
offset
|
| Dialog controls |
control-bg, control-text,
control-border, accent,
accent-text, danger, danger-text
|
| Toasts |
toast-width, toast-info,
toast-success, toast-warning,
toast-error
|
| Drawer | drawer-size |
| Anchored |
anchor-width, anchor-padding,
anchor-offset, menu-min-width
|
| Tooltip |
tooltip-bg, tooltip-text,
tooltip-width, tooltip-delay,
tooltip-hide-delay
|
The defaults follow prefers-color-scheme, so they read on light
and dark grounds untouched. They are declared through
:where(:root) - zero specificity - so your own
:root always wins wherever your stylesheet sits.
The skin adds no global resets and styles nothing outside its own
jaf-ui- classes.
To drop the skin entirely, headless renders your markup bare and
keeps only the behavior:
<Modal name="preview" headless>
<div class="my-own-card">...</div>
</Modal>
Animation
Overlays animate by default: a 180ms fade and scale for dialogs, a slide for
toasts. It is pure CSS - @starting-style with
transition-behavior: allow-discrete - so nothing is timed in
JavaScript, and prefers-reduced-motion: reduce collapses it to
instant.
Turn it off with animate="false" per instance or
{ animate: false } per call; retune it globally with
--jaf-overlay-duration and --jaf-overlay-ease.
What rides on the browser
These components are thin because the platform got good:
| Component | Native API | Fallback where it is missing |
|---|---|---|
| Modal, Drawer, promise dialogs |
<dialog>.showModal(): top layer, focus trap, ESC,
::backdrop
|
The open attribute, a position: fixed backdrop,
our own focus trap and ESC
|
| Popover, Tooltip, Menu |
Popover API: top layer, light dismiss (hint for tooltips, so
they do not dismiss a menu underneath)
|
A fixed-position element, our own outside-pointer and ESC listeners |
| Anchoring | CSS anchor positioning: the browser tracks scroll, resize and overflow forever | Measure per open: preferred side, flip if it does not fit, clamp |
| Animation |
@starting-style with
transition-behavior: allow-discrete
|
Instant. Nothing is timed in JavaScript |
Detection is per capability, never per browser, and the fallback positioning is deliberately three rules deep.
A real positioning engine is out of scope: if you need one, bring one and
drive it from ui.popover state.
The state registry
Under the components is a name-keyed state registry, and every component on this page renders it rather than owning it.
Reach for it directly for imperative control from outside a component, for hand-rolled overlays, or to drive markup that is not ours at all.
| Method | What it does |
|---|---|
ui.modal.open(name, data?, options?) |
Open it |
ui.modal.close(name?) |
Close it, or the topmost if no name |
ui.modal.closeAll() |
Close every modal |
ui.modal.isOpen(name) |
Reactive, so it drives conditional rendering |
ui.modal.toggle(name, data?) |
Toggle |
ui.modal.data(name) |
The data passed to open() |
ui.modal.bind(ref, options?) |
Drive it from a getter/setter pair |
ui.drawer has the same methods minus closeAll.
ui.popover has open, close,
isOpen, toggle, getAnchorRect and
bind, and its open(name, element) takes the anchor -
which is how <Popover>, <Tooltip> and
<Menu> learn what they hang off.
ui.drawer.toggle('nav')
ui.popover.open('row-actions', event.currentTarget)
Hand-rolled overlays
Nothing stops you writing the markup yourself:
<button onclick="ui.drawer.toggle('nav')">Menu</button>
{ui.drawer.isOpen('nav') && (
<aside class="drawer" data-ui-drawer="nav">
<nav><a href="/">Home</a></nav>
</aside>
)}
data-ui-modal="name", data-ui-drawer="name" or
data-ui-popover="name" on the outer element wires up
click-outside detection and focus trapping, and
data-ui-content on the inner panel marks what a backdrop click
must spare.
position: fixed just works here, because there is nothing between your
overlay and the page: a component is inserted at a comment anchor, not into a wrapper
element.
Changed in 0.1. JAF used to wrap every component in an element
carrying contain: layout style, which made a new containing block and
broke fixed positioning; data-jaf-no-contain was the opt-out. There is no
wrapper and no containment rule, so delete every data-jaf-no-contain in
your app - it means nothing now.
One thing the compiler does add to a hand-rolled overlay: the two-way
binding. When the conditional that wraps the overlay markup is somewhere a value
can be written back to, it emits a ui.modal.bind({ get, set }) keeping your
own variable and the registry in step.
| Guard | Bound |
|---|---|
{isOpen && ( |
Yes - a bare identifier |
{state.isOpen && ( |
Yes - a member expression |
{ui.modal.isOpen('nav') && ( |
No - a call is a value, not a place |
{!hidden && ( |
No - same reason |
| no conditional at all | No - there is no variable to bind |
The last three are fine: they are how you drive an overlay purely from the registry. The
compiler says so once at build time, naming the attribute and the component - it is a note,
not an error. One auto-binding per component; a second hand-rolled overlay in the same file
is named in the same message and left to ui.* directly.
Configuration
ui.configure({
modal: { closeOnBackdrop: false, history: false },
drawer: { trapFocus: true },
toast: { autoHost: false },
animationDuration: 200
})
// Per-call override
ui.modal.open('terms', null, { closeOnBackdrop: false })
Precedence runs global config, then the behavior declared on the tag, then per-call options.
| Option | Modal default | Drawer default |
|---|---|---|
closeOnEscape | true | true |
closeOnBackdrop | true | true |
trapFocus | true | false |
lockScroll | true | true |
closeOnNavigate | true | true |
history | true | true |
Someone else's overlays
The state layer is the product; the shells are optional. Every
ui.* surface is plain reactive state, so it can drive
any markup - a shadcn dialog, a Base UI popover, a React island.
Mixing JAF's <ToastHost> with an imported
<Dialog> is a normal setup, not a workaround, and nothing
in the skin can leak into an imported component.
<script>
// Anything that reads ui.modal.isOpen() re-runs when it changes.
effect(() => {
foreignDialog.classList.toggle('hidden', !ui.modal.isOpen('settings'))
})
</script>
ui.page is deprecated
Removed in v0.2. ui.page duplicated the
router. It still works and warns once per method in dev. Both of its
unique features moved to the router itself.
// Before
ui.page.open('/users/123', { user })
const data = ui.page.data
ui.page.close()
// Now
import { navigate } from 'jafjs/router'
navigate('/users/123', { data: { user } })
const data = route.data
history.back()
route.data is in-memory only: it is not serialised into history,
so a reload or a shared link gets undefined.
Anything that has to survive belongs in the URL.