On this pageOverview
Dialog
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.
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')],
})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',
),
],
})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.
| Attribute | Condition |
|---|---|
data-open | Present on the dialog when visible. |
data-closed | Present during close animation. |
data-transition | Present during any animation phase. |
data-enter | Present during the enter animation. |
data-leave | Present during the leave animation. |
| Key | Description |
|---|---|
| Escape | Closes the dialog. |
| Tab | Cycles focus within the dialog (focus trapping via showModal). |
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().
Configuration object passed to Dialog.init().
| Name | Type | Default | Description |
|---|---|---|---|
id | string | - | Unique ID for the dialog instance. |
isOpen | boolean | false | Initial open/closed state. |
isAnimated | boolean | false | Enables CSS transition coordination for open/close animations. |
focusSelector | string | - | CSS selector for the element to focus when the dialog opens. Defaults to the first focusable element. |
Configuration object passed to Dialog.view().
| Name | Type | Default | Description |
|---|---|---|---|
model | Dialog.Model | - | The dialog state from your parent Model. |
toParentMessage | (childMessage: Dialog.Message) => ParentMessage | - | Wraps Dialog Messages in your parent Message type for Submodel delegation. |
panelContent | Html | - | Content rendered inside the dialog panel. |
panelClassName | string | - | CSS class for the dialog panel. |
panelAttributes | ReadonlyArray<Attribute<Message>> | - | Additional attributes for the dialog panel. |
backdropClassName | string | - | CSS class for the backdrop overlay. |
backdropAttributes | ReadonlyArray<Attribute<Message>> | - | Additional attributes for the backdrop. |
onClosed | () => Message | - | Optional callback fired when the dialog closes, as an alternative to Submodel delegation. |
className | string | - | CSS class for the native <dialog> element. |
attributes | ReadonlyArray<Attribute<Message>> | - | Additional attributes for the native <dialog> element. |