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.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),
}),
],
)
}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),
})
}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.
| 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 animation 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. |
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()`. |
Payload delivered to the toView callback each render.
| Name | Type | Default | Description |
|---|---|---|---|
dialog | ReadonlyArray<ChildAttribute> | - | Spread onto an `h.dialog(...)` element. Carries the id, ARIA labelling, `open` prop, positioning style, and the Escape handler that wires to `RequestedClose`. |
backdrop | ReadonlyArray<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). |
panel | ReadonlyArray<ChildAttribute> | - | Spread onto the panel element. Includes the panel id (`${id}-panel`) and the Animation data attributes. |
isVisible | boolean | - | Derived from `isOpen` and the Animation `transitionState`. Render the backdrop and panel only while this is true. |
Messages emitted to the parent through the third element of [Model, Commands, Option<OutMessage>]. Pattern-match on the OutMessage in your update handler.
| Name | Type | Default | Description |
|---|---|---|---|
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. |