Modal
On this page
A modal is a <dialog>. Any button with commandfor set to the modal’s id and command: 'show-modal' opens it modally: the page behind goes inert, so focus and a screen reader both stay inside. No script anywhere — the backdrop, Escape and a click outside are all the browser’s.
That is why id is required and why the component throws without one: the id is the entire wiring.
Basic modal
Every modal on this page really opens — try it.
fragment(
button({ commandfor: 'demo-basic', command: 'show-modal' }, 'Open modal'),
modal({ id: 'demo-basic', title: 'Rebuild the site?' },
'This runs sitelo build and republishes dist/.',
),
)With a footer
A close button is any button pointing at the same id with command: 'close'.
fragment(
button({ color: 'danger', commandfor: 'demo-confirm', command: 'show-modal' }, 'Delete page…'),
modal({
id: 'demo-confirm',
title: 'Delete this page?',
footer: stack({ direction: 'row', gap: 'sm' },
button({
variant: 'ghost',
color: 'neutral',
commandfor: 'demo-confirm',
command: 'close',
}, 'Cancel'),
button({ color: 'danger' }, 'Delete'),
),
}, 'This cannot be undone. The generated HTML is removed on the next build.'),
)Sizes
fragment(
stack({ direction: 'row', gap: 'sm', wrap: true },
button({ variant: 'outline', color: 'neutral', commandfor: 'demo-sm', command: 'show-modal' }, 'Small'),
button({ variant: 'outline', color: 'neutral', commandfor: 'demo-md', command: 'show-modal' }, 'Medium'),
button({ variant: 'outline', color: 'neutral', commandfor: 'demo-lg', command: 'show-modal' }, 'Large'),
),
modal({ id: 'demo-sm', size: 'sm', title: 'Small' }, 'size: sm — about 24rem.'),
modal({ id: 'demo-md', title: 'Medium' }, 'The default — about 32rem.'),
modal({ id: 'demo-lg', size: 'lg', title: 'Large' }, 'size: lg — about 48rem.'),
)Forms inside a modal
fragment(
button({ variant: 'soft', commandfor: 'demo-form', command: 'show-modal' }, 'New page…'),
modal({
id: 'demo-form',
title: 'New page',
footer: stack({ direction: 'row', gap: 'sm' },
button({ variant: 'ghost', color: 'neutral', commandfor: 'demo-form', command: 'close' }, 'Cancel'),
button({ type: 'submit' }, 'Create'),
),
},
stack({ gap: 'md' },
textField({ label: 'Title', name: 'modal-title', placeholder: 'About' }),
selectField({ label: 'Extension', name: 'modal-ext', options: ['.ht.js', '.ht.ts', '.ht.jsx'] }),
),
),
)Without a close button
closable: false drops the × in the corner. Escape and clicking outside still close it; with closedby: 'closerequest' only Escape does.
fragment(
button({ variant: 'outline', color: 'neutral', commandfor: 'demo-bare', command: 'show-modal' }, 'No close button'),
modal({ id: 'demo-bare', title: 'Press Escape', closable: false },
'Or click anywhere outside this dialog.',
),
)Long content
The body scrolls; the header and footer stay put.
fragment(
button({ variant: 'outline', color: 'neutral', commandfor: 'demo-long', command: 'show-modal' }, 'Long modal'),
modal({
id: 'demo-long',
title: 'Release notes',
footer: button({ commandfor: 'demo-long', command: 'close' }, 'Close'),
},
stack({ gap: 'md' },
...Array.from({ length: 12 }, (unused, index) =>
text({ variant: 'small', tone: 'muted' }, 'Change ' + (index + 1) + ' — something was fixed.'),
),
),
),
)Background scrolling
The page behind an open modal does not scroll. That is the one thing a modal dialog leaves to you, and it is done in CSS here — no script, and nothing to initialise. Pass lockScroll: false to let the background scroll as usual.
Browser support
Opening a dialog from a button’s command works in every current browser — Chrome 135, Firefox 144 and Safari 26.2 or later. In an older one, button() adds an onclick that fetches a few hundred bytes of /su/dialog.js to do the same — only there, and only on the first click. Safari does not close a dialog on a click outside (closedby) yet, and the same file does that there.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
id | string | — | Required. What a trigger’s commandfor points at. |
title | Child | — | Heading, and the dialog’s accessible name. |
size | 'sm' | 'md' | 'lg' | 'md' | Maximum width. |
footer | Child | — | Bottom row, on its own tinted band. |
closable | boolean | true | Show the × in the header. |
closeLabel | string | 'Close' | Accessible name for that button. |
lockScroll | boolean | true | Stop the page behind it scrolling while it is open. |
closeButton({ target }) renders that × on its own, for a header you build yourself.