Skip to main content
On this pageOverview

Dialog

Overview

A modal dialog backed by the native <dialog> element. Uses showModal() for focus trapping, backdrop rendering, and scroll locking — no JavaScript focus trap needed. For non-modal floating content, use Popover instead.

See it in an app

Check out how Dialog is wired up in a real Foldkit app.

Examples

Basic

Open the dialog by dispatching Dialog.Opened() and close it with Dialog.Closed(). For programmatic control in update functions, use Dialog.open(model) and Dialog.close(model) which return [Model, Commands] directly. Use Dialog.titleId(model) on a heading element so the dialog is labeled for screen readers.

// Pseudocode walkthrough of the Foldkit integration points. Each labeled
// block below is an excerpt — fit them into your own Model, init, Message,
// update, and view definitions.
import { Effect } from 'effect'
import { Command, Ui } from 'foldkit'
import { m } from 'foldkit/message'
import { evo } from 'foldkit/struct'

import { Class, Id, OnClick, button, div, h2, p } from './html'

// Add a field to your Model for the Dialog Submodel:
const Model = S.Struct({
  dialog: Ui.Dialog.Model,
  // ...your other fields
})

// In your init function, initialize the Dialog Submodel with a unique id:
const init = () => [
  {
    dialog: Ui.Dialog.init({ id: 'confirm' }),
    // ...your other fields
  },
  [],
]

// Embed the Dialog Message in your parent Message:
const GotDialogMessage = m('GotDialogMessage', {
  message: Ui.Dialog.Message,
})

// Inside your update function's M.tagsExhaustive({...}), delegate to Dialog.update:
GotDialogMessage: ({ message }) => {
  const [nextDialog, commands] = Ui.Dialog.update(model.dialog, message)

  return [
    // Merge the next state into your Model:
    evo(model, { dialog: () => nextDialog }),
    // Forward the Submodel's Commands through your parent Message:
    commands.map(
      Command.mapEffect(Effect.map(message => GotDialogMessage({ message }))),
    ),
  ]
}

// Helper to convert Dialog Messages to your parent Message:
const dialogToParentMessage = (message: Ui.Dialog.Message): Message =>
  GotDialogMessage({ message })

// Inside your view function, open the dialog by dispatching Ui.Dialog.Opened():
button([OnClick(dialogToParentMessage(Ui.Dialog.Opened()))], ['Open Dialog'])

// And render the dialog — backed by native <dialog> with showModal():
Ui.Dialog.view({
  model: model.dialog,
  toParentMessage: dialogToParentMessage,
  backdropAttributes: [Class('fixed inset-0 bg-black/50')],
  panelContent: div(
    [],
    [
      h2([Id(Ui.Dialog.titleId(model.dialog))], ['Confirm Action']),
      p([], ['Are you sure you want to proceed?']),
      button(
        [
          OnClick(dialogToParentMessage(Ui.Dialog.Closed())),
          Class('px-4 py-2 rounded-lg border'),
        ],
        ['Close'],
      ),
    ],
  ),
  panelAttributes: [Class('rounded-lg p-6 max-w-md mx-auto shadow-xl')],
})

Animated

Pass isAnimated: true at init to coordinate CSS transitions. The component manages a Transition submodel internally — apply transition classes using data-closed (e.g. data-[closed]:opacity-0 data-[closed]:scale-95).

// Pseudocode walkthrough of the Foldkit integration points. Each labeled
// block below is an excerpt — fit them into your own Model, init, Message,
// update, and view definitions.
import { Effect } from 'effect'
import { Command, Ui } from 'foldkit'
import { m } from 'foldkit/message'
import { evo } from 'foldkit/struct'

import { Class, Id, OnClick, button, div, h2, p } from './html'

// Add a field to your Model for the Dialog Submodel:
const Model = S.Struct({
  dialog: Ui.Dialog.Model,
  // ...your other fields
})

// In your init function, set isAnimated: true to coordinate CSS transitions:
const init = () => [
  {
    dialog: Ui.Dialog.init({ id: 'confirm', isAnimated: true }),
    // ...your other fields
  },
  [],
]

// Embed the Dialog Message in your parent Message:
const GotDialogMessage = m('GotDialogMessage', {
  message: Ui.Dialog.Message,
})

// Inside your update function's M.tagsExhaustive({...}), delegate to Dialog.update:
GotDialogMessage: ({ message }) => {
  const [nextDialog, commands] = Ui.Dialog.update(model.dialog, message)

  return [
    // Merge the next state into your Model:
    evo(model, { dialog: () => nextDialog }),
    // Forward the Submodel's Commands through your parent Message:
    commands.map(
      Command.mapEffect(Effect.map(message => GotDialogMessage({ message }))),
    ),
  ]
}

// Helper to convert Dialog Messages to your parent Message:
const dialogToParentMessage = (message: Ui.Dialog.Message): Message =>
  GotDialogMessage({ message })

// Inside your view function, use data-[closed] for enter/leave transitions:
Ui.Dialog.view({
  model: model.dialog,
  toParentMessage: dialogToParentMessage,
  backdropAttributes: [
    Class(
      'fixed inset-0 bg-black/50 transition duration-150 ease-out data-[closed]:opacity-0',
    ),
  ],
  panelContent: div(
    [],
    [
      h2([Id(Ui.Dialog.titleId(model.dialog))], ['Confirm Action']),
      p([], ['Are you sure you want to proceed?']),
      div(
        [Class('flex gap-2 justify-end mt-4')],
        [
          button(
            [
              OnClick(dialogToParentMessage(Ui.Dialog.Closed())),
              Class('px-4 py-2 rounded-lg border'),
            ],
            ['Cancel'],
          ),
          button(
            [
              OnClick(dialogToParentMessage(Ui.Dialog.Closed())),
              Class('px-4 py-2 rounded-lg bg-blue-600 text-white'),
            ],
            ['Confirm'],
          ),
        ],
      ),
    ],
  ),
  panelAttributes: [
    Class(
      'rounded-lg p-6 max-w-md mx-auto shadow-xl transition duration-150 ease-out data-[closed]:opacity-0 data-[closed]:scale-95',
    ),
  ],
  attributes: [
    Class(
      'backdrop:bg-transparent bg-transparent p-0 open:flex items-center justify-center',
    ),
  ],
})

Styling

Dialog is headless — you control the panel and backdrop markup through className and attribute props. The native <dialog> element handles the top layer, so style its ::backdrop as backdrop:bg-transparent and render your own custom backdrop for full control.

AttributeCondition
data-openPresent on the dialog when visible.
data-closedPresent during close animation.
data-transitionPresent during any animation phase.
data-enterPresent during the enter animation.
data-leavePresent during the leave animation.

Keyboard Interaction

KeyDescription
EscapeCloses the dialog.
TabCycles focus within the dialog (focus trapping via showModal).

Accessibility

The dialog receives aria-labelledby pointing to the title element (use Dialog.titleId(model)) and aria-describedby pointing to a description element (use Dialog.descriptionId(model)). Focus trapping is handled natively by showModal().

API Reference

InitConfig

Configuration object passed to Dialog.init().

NameTypeDefaultDescription
idstring-Unique ID for the dialog instance.
isOpenbooleanfalseInitial open/closed state.
isAnimatedbooleanfalseEnables CSS transition coordination for open/close animations.
focusSelectorstring-CSS selector for the element to focus when the dialog opens. Defaults to the first focusable element.

ViewConfig

Configuration object passed to Dialog.view().

NameTypeDefaultDescription
modelDialog.Model-The dialog state from your parent Model.
toParentMessage(childMessage: Dialog.Message) => ParentMessage-Wraps Dialog Messages in your parent Message type for Submodel delegation.
panelContentHtml-Content rendered inside the dialog panel.
panelClassNamestring-CSS class for the dialog panel.
panelAttributesReadonlyArray<Attribute<Message>>-Additional attributes for the dialog panel.
backdropClassNamestring-CSS class for the backdrop overlay.
backdropAttributesReadonlyArray<Attribute<Message>>-Additional attributes for the backdrop.
onClosed() => Message-Optional callback fired when the dialog closes, as an alternative to Submodel delegation.
classNamestring-CSS class for the native <dialog> element.
attributesReadonlyArray<Attribute<Message>>-Additional attributes for the native <dialog> element.

Stay in the update loop.

New releases, patterns, and the occasional deep dive.


Built with Foldkit.

© 2026 Devin Jameson