Modal
A modal is a popover element. Any button whose popovertarget matches the modal’s id opens it — no script anywhere, including the backdrop, light dismiss, Escape and focus handling, all of which the browser owns.
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.
Rebuild the site?
fragment(
button({ popovertarget: 'demo-basic' }, '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 popovertargetaction="hide".
Delete this page?
fragment(
button({ color: 'danger', popovertarget: 'demo-confirm' }, 'Delete page…'),
modal({
id: 'demo-confirm',
title: 'Delete this page?',
footer: stack({ direction: 'row', gap: 'sm' },
button({
variant: 'ghost',
color: 'neutral',
popovertarget: 'demo-confirm',
popovertargetaction: 'hide',
}, 'Cancel'),
button({ color: 'danger' }, 'Delete'),
),
}, 'This cannot be undone. The generated HTML is removed on the next build.'),
)Sizes
Small
Medium
Large
fragment(
stack({ direction: 'row', gap: 'sm', wrap: true },
button({ variant: 'outline', color: 'neutral', popovertarget: 'demo-sm' }, 'Small'),
button({ variant: 'outline', color: 'neutral', popovertarget: 'demo-md' }, 'Medium'),
button({ variant: 'outline', color: 'neutral', popovertarget: 'demo-lg' }, '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
New page
fragment(
button({ variant: 'soft', popovertarget: 'demo-form' }, 'New page…'),
modal({
id: 'demo-form',
title: 'New page',
footer: stack({ direction: 'row', gap: 'sm' },
button({ variant: 'ghost', color: 'neutral', popovertarget: 'demo-form', popovertargetaction: 'hide' }, '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 — a popover cannot be made truly modal-blocking, and that is usually the right behaviour anyway.
Press Escape
fragment(
button({ variant: 'outline', color: 'neutral', popovertarget: 'demo-bare' }, '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.
Release notes
Change 1 — something was fixed.
Change 2 — something was fixed.
Change 3 — something was fixed.
Change 4 — something was fixed.
Change 5 — something was fixed.
Change 6 — something was fixed.
Change 7 — something was fixed.
Change 8 — something was fixed.
Change 9 — something was fixed.
Change 10 — something was fixed.
Change 11 — something was fixed.
Change 12 — something was fixed.
fragment(
button({ variant: 'outline', color: 'neutral', popovertarget: 'demo-long' }, 'Long modal'),
modal({
id: 'demo-long',
title: 'Release notes',
footer: button({ popovertarget: 'demo-long', popovertargetaction: 'hide' }, 'Close'),
},
stack({ gap: 'md' },
...Array.from({ length: 12 }, (unused, index) =>
text({ variant: 'small', tone: 'muted' }, 'Change ' + (index + 1) + ' — something was fixed.'),
),
),
),
)Browser support
The popover API is available in every current browser. In one too old to know it, the modal renders inline in the page instead of on top of it — visible and usable, just not overlaid. Nothing disappears.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
id | string | — | Required. What a trigger’s popovertarget 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. |
closeButton({ target }) renders that × on its own, for a header you build yourself.