Modal

Give an important moment its own space. Native dialogs with deliberate motion, flexible composition, and focus that returns where it belongs.

Modal · ModalTrigger

View source · API reference

Playground

Open the dialog and try its close controls, backdrop, and Escape key. Focus returns to the trigger after dismissal. This example is entirely local.

View source
<ModalTrigger target="publish-dialog" label="Review publication" tone="accent"/>
<Modal id="publish-dialog" title="Ready to share your work?" tone="neutral" size="md" :showClose="true">
  <p>Your next idea deserves a place on the web. This preview keeps everything local.</p>
  <template #footer><Btn tone="neutral" variant="surface" data-modal-close>Keep editing</Btn><Btn tone="accent" data-modal-close>Looks good</Btn></template>
</Modal>

Usage

Connect ModalTrigger.target to a unique Modal.id. Set a title and explicitly enable :showClose="true" when you want the built-in close button. Add data-modal-close to your own dismissal actions.

A native dialog, with a runtime

PureStack automatically includes the modal runtime when you render Modal in MDX. The runtime opens the native dialog, manages focus, and coordinates dismissal animations. Opening a modal requires JavaScript.

Sizes

Choose sm, md, lg, or xl to match the amount of content. All sizes remain constrained by the viewport, and long content scrolls inside the panel.

View source
<Flex wrap="true"><ModalTrigger target="size-sm" label="SM dialog" tone="neutral" variant="surface"/>
<Modal id="size-sm" title="SM dialog" size="sm" :showClose="true"><p>Size follows the modal’s built-in responsive width constraints.</p><template #footer><Btn tone="accent" data-modal-close>Done</Btn></template></Modal>
<ModalTrigger target="size-md" label="MD dialog" tone="neutral" variant="surface"/>
<Modal id="size-md" title="MD dialog" size="md" :showClose="true"><p>Size follows the modal’s built-in responsive width constraints.</p><template #footer><Btn tone="accent" data-modal-close>Done</Btn></template></Modal>
<ModalTrigger target="size-lg" label="LG dialog" tone="neutral" variant="surface"/>
<Modal id="size-lg" title="LG dialog" size="lg" :showClose="true"><p>Size follows the modal’s built-in responsive width constraints.</p><template #footer><Btn tone="accent" data-modal-close>Done</Btn></template></Modal>
<ModalTrigger target="size-xl" label="XL dialog" tone="neutral" variant="surface"/>
<Modal id="size-xl" title="XL dialog" size="xl" :showClose="true"><p>Size follows the modal’s built-in responsive width constraints.</p><template #footer><Btn tone="accent" data-modal-close>Done</Btn></template></Modal></Flex>

Motion

slideFrom controls the direction of the entrance and exit. fade controls the fade independently. Use :fade="false" for a real boolean rather than a string.

View source
<Flex wrap="true"><ModalTrigger target="motion-none" label="none" variant="surface" tone="neutral"/>
<Modal id="motion-none" title="Slide: none" slideFrom="none" :fade="false" :showClose="true"><p>Dismiss with Escape or the button below.</p><template #footer><Btn data-modal-close tone="accent">Close preview</Btn></template></Modal>
<ModalTrigger target="motion-top" label="top" variant="surface" tone="neutral"/>
<Modal id="motion-top" title="Slide: top" slideFrom="top" :fade="true" :showClose="true"><p>Dismiss with Escape or the button below.</p><template #footer><Btn data-modal-close tone="accent">Close preview</Btn></template></Modal>
<ModalTrigger target="motion-right" label="right" variant="surface" tone="neutral"/>
<Modal id="motion-right" title="Slide: right" slideFrom="right" :fade="true" :showClose="true"><p>Dismiss with Escape or the button below.</p><template #footer><Btn data-modal-close tone="accent">Close preview</Btn></template></Modal>
<ModalTrigger target="motion-bottom" label="bottom" variant="surface" tone="neutral"/>
<Modal id="motion-bottom" title="Slide: bottom" slideFrom="bottom" :fade="true" :showClose="true"><p>Dismiss with Escape or the button below.</p><template #footer><Btn data-modal-close tone="accent">Close preview</Btn></template></Modal>
<ModalTrigger target="motion-left" label="left" variant="surface" tone="neutral"/>
<Modal id="motion-left" title="Slide: left" slideFrom="left" :fade="true" :showClose="true"><p>Dismiss with Escape or the button below.</p><template #footer><Btn data-modal-close tone="accent">Close preview</Btn></template></Modal></Flex>

Slots and composition

Use the header, body, and footer slots to compose a task-specific surface. The default slot supplies body content when no body slot is present.

When replacing the header, preserve the title connection: for id="custom-dialog", provide an element with id="custom-dialog-title".

View source
<ModalTrigger target="custom-dialog" label="Open project summary" tone="accent" variant="surface"/>
<Modal id="custom-dialog" tone="neutral" size="lg" :showClose="true">
  <template #header><div class="p-4"><Badge tone="accent">Project preview</Badge><h2 id="custom-dialog-title">A place for your next idea.</h2></div></template>
  <template #body><Grid columns="1" columnsSm="2"><Panel tone="neutral"><h3>Content</h3><p>Markdown and components, together.</p></Panel><Panel tone="accent"><h3>Interface</h3><p>Shared themes. Native browser output.</p></Panel></Grid></template>
  <template #footer><Btn tone="accent" data-modal-close>Back to the docs</Btn></template>
</Modal>

Full content override

The content slot replaces the entire interior, including the built-in close button. Retain .modal__panel for size, motion, and focus handling. Provide a heading connected to the dialog and at least one clear dismissal control.

View source
<ModalTrigger target="custom-content-dialog" label="Open custom surface" tone="neutral" variant="surface"/>
<Modal id="custom-content-dialog" :fade="false" size="sm">
  <template #content><article class="modal__panel p-6" role="document" tabindex="-1"><h2 id="custom-content-dialog-title">Your own composition.</h2><p>You own the interior. PureStack manages opening, dismissal, and focus.</p><Btn tone="accent" data-modal-close>Got it</Btn></article></template>
</Modal>

Nested dialogs

A trigger inside one dialog can open another. Close the child to return to the parent’s trigger, then close the parent to return to the page.

View source
<ModalTrigger target="parent-dialog" label="Open nested example" tone="neutral" variant="surface"/>
<Modal id="parent-dialog" title="Project settings" :showClose="true"><p>Review a second dialog without losing your place.</p><template #footer><Btn tone="neutral" variant="surface" data-modal-close>Close settings</Btn><ModalTrigger target="child-dialog" label="Review details" tone="accent"/></template></Modal>
<Modal id="child-dialog" title="A little more detail" :showClose="false" size="sm"><p>This dialog uses a footer action in place of the built-in close button.</p><template #footer><Btn tone="accent" data-modal-close>Return to settings</Btn></template></Modal>

Browser API

Open or close an existing dialog with window.tsSsgModal.open(id) and .close(id). Call .refresh() after inserting dialogs dynamically so their triggers and close controls are bound.

Ready to open.

View source
<Flex direction="column" align="start">
  <Btn tone="accent" @click="open">Open with the browser API</Btn>
  <p role="status">{{ status }}</p>
  <Modal id="api-dialog" title="A programmatic dialog" :showClose="true" @close="closed" @cancel="cancelled">
    <p>This dialog was opened by a Regor event handler.</p>
    <template #footer><Btn tone="accent" @click="close">Close with the API</Btn></template>
  </Modal>
</Flex>
**Browser TypeScript bindings**
import { ref } from 'regor'

const status = ref('Ready to open.')
const open = () => {
  status('Dialog opened.')
  window.tsSsgModal?.open('api-dialog')
}
const close = () => window.tsSsgModal?.close('api-dialog')
const closed = () => status('Dialog closed.')
const cancelled = () => status('Dismissed with Escape.')

// Pass these bindings into createApp with defineModalComponents(),
// defineButtonComponents(), defineFlexComponents(), and
// defineIconComponents(). After mounting, call
// window.tsSsgModal?.refresh().

Dialogs from application state

For dialogs created from application state, register defineModalComponents(), mount <ModalStore/> once in your live Regor application, and call useModalStore().openModal(options). The store accepts the modal props plus body text, a note, and actions with asynchronous handlers.

Your draft is ready for review.

View source
<Flex direction="column" align="start">
  <ModalStore/>
  <Btn tone="accent" variant="surface" @click="review">Review a saved draft</Btn>
  <p role="status">{{ result }}</p>
</Flex>
**Browser TypeScript bindings**
import { ref } from 'regor'
import { useModalStore } from '@purestack/ts-components'

const result = ref('Your draft is ready for review.')
const review = () => useModalStore().openModal({
  title: 'Save this draft?',
  body: 'This example updates local demo state.',
  note: 'No data is sent to a server.',
  tone: 'neutral',
  showClose: true,
  actions: [
    { label: 'Keep editing', variant: 'surface' },
    {
      label: 'Save draft',
      tone: 'accent',
      onClick: () => { result('Draft saved in this demo.') },
    },
  ],
})

// Pass result and review into createApp with defineModalComponents(),
// defineButtonComponents(), defineFlexComponents(), and
// defineIconComponents(). Render ModalStore before invoking review.

Accessibility

  • Supply a meaningful title or an equivalent accessible name for every dialog.
  • Keep a visible dismissal action. showClose affects the built-in button; Escape and the backdrop remain available.
  • The runtime traps Tab focus inside the dialog and restores focus to the opener.
  • Preserve the title id and focusable panel when overriding slots.
  • Use @close and @cancel in a mounted app when application state needs to respond to dismissal.

API reference

Presentation uses the same tones and variants as other PureStack components. A modal’s default variant mode is stateless, so the surface does not change merely because the pointer crosses it.

Name Type Default Description
id string - Provide a document-unique id that matches ModalTrigger.target.
title string - Visible and accessible dialog title. A custom header must provide {id}-title.
tone SemanticTone neutral (inherited) neutral · accent · feature · secondary · custom · ghost · info · success · warning · danger
variant ComponentVariant surface Styles the interior surface.
variantMode 'stateful' | 'stateless' stateless Controls whether the variant includes hover and active styles.
size 'sm' | 'md' | 'lg' | 'xl' md Responsive maximum width of the panel.
fade boolean true Enables the fade animation. Use a bound boolean for false.
slideFrom 'none' | 'top' | 'right' | 'bottom' | 'left' none Entrance/exit direction of the centered panel.
showClose boolean false Shows the built-in close control. Footer actions and Escape still dismiss the dialog.

ModalTrigger

Name Type Default Description
target string - The target Modal id, without #.
label string - Visible trigger text.
tone SemanticTone neutral (inherited) neutral · accent · feature · secondary · custom · ghost · info · success · warning · danger
variant ComponentVariant solid Uses the standard Btn appearance variants.

Slots, events, and runtime

Name Type Default Description
default / body slot - Main content. The body slot takes precedence over default content.
header named slot - Replaces the title region. The built-in close button remains controlled by showClose.
footer named slot - Action area below the body.
content named slot - Replaces the entire interior. Retain .modal__panel and an accessible name.
@close / @cancel native dialog events - Observe dismissal in a browser application. The runtime handles cancel to animate closing.
data-modal-close native attribute - Marks a control inside the dialog as a dismissal action.
window.tsSsgModal.open(id) (id: string) => void - Opens an existing dialog programmatically.
window.tsSsgModal.close(id) (id: string) => void - Closes an existing dialog with its configured animation.
window.tsSsgModal.refresh(target?) (selector?: string) => void automatic at load Binds dynamically inserted dialogs and triggers.

ModalStore · browser applications

Name Type Default Description
<ModalStore/> component - Mount once in a live Regor app before calling useModalStore().
openModal(options) ModalContract → modal item - Creates and opens a dialog. Accepts Modal props plus body, note, and actions.
closeModal(id) / removeModal(id) (id: string) => void - Close an active dialog or remove a stored item.
actions ModalActionContract[] - Supports id, label, tone, variant, icon, disabled, closeOnClick (true), and onClick(modal, store). Async handlers are awaited.