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.RequestedOpen() and close it with Dialog.RequestedClose(). For programmatic control in update functions, use Dialog.open(model) and Dialog.close(model) which return [Model, Commands, Option<OutMessage>] 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 { Match as M, Option } from 'effect'
import { Command, Ui } from 'foldkit'
import { html } from 'foldkit/html'
import { m } from 'foldkit/message'
import { evo } from 'foldkit/struct'

// 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
// Ui.Dialog.update. The OutMessages `Opened` and `Closed` mark the
// transition moments. Fire analytics, reset embedded form state, or
// kick off side effects from the parent.
GotDialogMessage: ({ message }) => {
  const [nextDialog, commands, maybeOutMessage] = Ui.Dialog.update(
    model.dialog,
    message,
  )
  const mappedCommands = Command.mapMessages(commands, message =>
    GotDialogMessage({ message }),
  )

  return Option.match(maybeOutMessage, {
    onNone: () => [evo(model, { dialog: () => nextDialog }), mappedCommands],
    onSome: M.type<Ui.Dialog.OutMessage>().pipe(
      M.tagsExhaustive({
        Opened: () => [
          // The child has emitted `Opened`. The body commits the
          // child's next state as usual. In this arm the parent can
          // also update its own state or dispatch its own Commands,
          // for example log analytics, manage focus, or fetch
          // initial data.
          evo(model, { dialog: () => nextDialog }),
          mappedCommands,
        ],
        Closed: () => [
          // The child has emitted `Closed`. The body commits the
          // child's next state as usual. In this arm the parent can
          // also update its own state or dispatch its own Commands,
          // for example clear ephemeral state or resolve a pending
          // domain action.
          evo(model, { dialog: () => nextDialog }),
          mappedCommands,
        ],
      }),
    ),
  })
}

// 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.RequestedOpen()
// and render the dialog, backed by native <dialog> with showModal():
const view = () => {
  const h = html<Message>()

  return h.div(
    [],
    [
      h.button(
        [h.OnClick(dialogToParentMessage(Ui.Dialog.RequestedOpen()))],
        ['Open Dialog'],
      ),
      h.submodel({
        slotId: model.dialog.id,
        model: model.dialog,
        view: Ui.Dialog.view,
        viewInputs: {
          toView: ({ dialog, backdrop, panel, isVisible }) =>
            h.dialog(
              [...dialog],
              isVisible
                ? [
                    h.div(
                      [...backdrop, h.Class('fixed inset-0 bg-black/50')],
                      [],
                    ),
                    h.div(
                      [
                        ...panel,
                        h.Class('rounded-lg p-6 max-w-md mx-auto shadow-xl'),
                      ],
                      [
                        h.h2(
                          [h.Id(Ui.Dialog.titleId(model.dialog))],
                          ['Confirm Action'],
                        ),
                        h.p([], ['Are you sure you want to proceed?']),
                        h.button(
                          [
                            h.OnClick(
                              dialogToParentMessage(Ui.Dialog.RequestedClose()),
                            ),
                            h.Class('px-4 py-2 rounded-lg border'),
                          ],
                          ['Close'],
                        ),
                      ],
                    ),
                  ]
                : [],
            ),
        },
        toParentMessage: message => dialogToParentMessage(message),
      }),
    ],
  )
}

Animated

Pass isAnimated: true at init to coordinate animations. The component manages an Animation 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 { Match as M, Option } from 'effect'
import { Command, Ui } from 'foldkit'
import { html } from 'foldkit/html'
import { m } from 'foldkit/message'
import { evo } from 'foldkit/struct'

// 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
// Ui.Dialog.update. The OutMessages `Opened` and `Closed` mark the
// transition moments. Fire analytics, reset embedded form state, or
// kick off side effects from the parent.
GotDialogMessage: ({ message }) => {
  const [nextDialog, commands, maybeOutMessage] = Ui.Dialog.update(
    model.dialog,
    message,
  )
  const mappedCommands = Command.mapMessages(commands, message =>
    GotDialogMessage({ message }),
  )

  return Option.match(maybeOutMessage, {
    onNone: () => [evo(model, { dialog: () => nextDialog }), mappedCommands],
    onSome: M.type<Ui.Dialog.OutMessage>().pipe(
      M.tagsExhaustive({
        Opened: () => [
          // The child has emitted `Opened`. The body commits the
          // child's next state as usual. In this arm the parent can
          // also update its own state or dispatch its own Commands,
          // for example log analytics, manage focus, or fetch
          // initial data.
          evo(model, { dialog: () => nextDialog }),
          mappedCommands,
        ],
        Closed: () => [
          // The child has emitted `Closed`. The body commits the
          // child's next state as usual. In this arm the parent can
          // also update its own state or dispatch its own Commands,
          // for example clear ephemeral state or resolve a pending
          // domain action.
          evo(model, { dialog: () => nextDialog }),
          mappedCommands,
        ],
      }),
    ),
  })
}

// 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:
const view = (model: Model) => {
  const h = html<Message>()

  return h.submodel({
    slotId: model.dialog.id,
    model: model.dialog,
    view: Ui.Dialog.view,
    viewInputs: {
      toView: ({ dialog, backdrop, panel, isVisible }) =>
        h.dialog(
          [
            ...dialog,
            h.Class(
              'backdrop:bg-transparent bg-transparent p-0 open:flex items-center justify-center',
            ),
          ],
          isVisible
            ? [
                h.div(
                  [
                    ...backdrop,
                    h.Class(
                      'fixed inset-0 bg-black/50 transition duration-150 ease-out data-[closed]:opacity-0',
                    ),
                  ],
                  [],
                ),
                h.div(
                  [
                    ...panel,
                    h.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',
                    ),
                  ],
                  [
                    h.h2(
                      [h.Id(Ui.Dialog.titleId(model.dialog))],
                      ['Confirm Action'],
                    ),
                    h.p([], ['Are you sure you want to proceed?']),
                    h.div(
                      [h.Class('flex gap-2 justify-end mt-4')],
                      [
                        h.button(
                          [
                            h.OnClick(
                              dialogToParentMessage(Ui.Dialog.RequestedClose()),
                            ),
                            h.Class('px-4 py-2 rounded-lg border'),
                          ],
                          ['Cancel'],
                        ),
                        h.button(
                          [
                            h.OnClick(
                              dialogToParentMessage(Ui.Dialog.RequestedClose()),
                            ),
                            h.Class(
                              'px-4 py-2 rounded-lg bg-blue-600 text-white',
                            ),
                          ],
                          ['Confirm'],
                        ),
                      ],
                    ),
                  ],
                ),
              ]
            : [],
        ),
    },
    toParentMessage: message => dialogToParentMessage(message),
  })
}

Styling

Dialog is headless. The toView callback receives attribute bundles for the dialog, backdrop, and panel, and the consumer composes the markup. 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.

When isAnimated is true, enter/leave animations flow through the Animation module. Style with CSS transitions or CSS keyframe animations. Animation advances once every animation on the element has settled.

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 animation 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.
toView(render: RenderInfo) => Html-Callback that receives the dialog, backdrop, and panel attribute bundles plus a derived `isVisible` flag, and returns the composed layout. The consumer MUST render an `h.dialog(...)` element so the framework can target it with `showModal()` / `close()`.

RenderInfo

Payload delivered to the toView callback each render.

NameTypeDefaultDescription
dialogReadonlyArray<ChildAttribute>-Spread onto an `h.dialog(...)` element. Carries the id, ARIA labelling, `open` prop, positioning style, and the Escape handler that wires to `RequestedClose`.
backdropReadonlyArray<ChildAttribute>-Spread onto the backdrop element. Includes the Animation data attributes and the outside-click handler that dispatches `RequestedClose` (suppressed while a leave animation is in progress).
panelReadonlyArray<ChildAttribute>-Spread onto the panel element. Includes the panel id (`${id}-panel`) and the Animation data attributes.
isVisibleboolean-Derived from `isOpen` and the Animation `transitionState`. Render the backdrop and panel only while this is true.

OutMessage

Messages emitted to the parent through the third element of [Model, Commands, Option<OutMessage>]. Pattern-match on the OutMessage in your update handler.

NameTypeDefaultDescription
Opened{}-Emitted once the dialog has transitioned to open. Fires after `update` has processed `RequestedOpen` and `isOpen` reflects the new state.
Closed{}-Emitted once the dialog has transitioned to closed. Programmatic `Dialog.close` on an already-closed model is a no-op that does not re-emit, as is calling close while a leave animation is already in progress.

Stay in the update loop.

New releases, patterns, and the occasional deep dive.


Built with Foldkit.

© 2026 Devin Jameson