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.

fragment(
  button({ popovertarget: 'demo-basic' }, 'Open modal'),
  modal({ id: 'demo-basic', title: 'Rebuild the site?' },
    'This runs sitelo build and republishes dist/.',
  ),
)

A close button is any button pointing at the same id with popovertargetaction="hide".

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

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

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.

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.

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

PropTypeDefaultDescription
idstringRequired. What a trigger’s popovertarget points at.
titleChildHeading, and the dialog’s accessible name.
size'sm' | 'md' | 'lg''md'Maximum width.
footerChildBottom row, on its own tinted band.
closablebooleantrueShow the × in the header.
closeLabelstring'Close'Accessible name for that button.

closeButton({ target }) renders that × on its own, for a header you build yourself.