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.

A modal is two things: something that opens it, and <Modal>.

The surface, backdrop, ESC key, focus trap, scroll lock and animation already exist.

source
<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>
output

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.

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:

source
<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>
output

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.

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.

source
<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>
output

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:

source
<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>
output

<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.

source
<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>
output

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.

source
<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>
output

Placement defaults to top.

Delays come from --jaf-overlay-tooltip-delay and -hide-delay; delay and hideDelay override them per instance, in milliseconds.

<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.

source
<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>
output

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.

source
<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>
output

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:

source
<!-- 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:

source
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.

source
<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>
output

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:

source
: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:

source
<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.

source
ui.drawer.toggle('nav')
ui.popover.open('row-actions', event.currentTarget)

Hand-rolled overlays

Nothing stops you writing the markup yourself:

source
<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

source
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
closeOnEscapetruetrue
closeOnBackdroptruetrue
trapFocustruefalse
lockScrolltruetrue
closeOnNavigatetruetrue
historytruetrue

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.

source
<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.

source
// 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.