Overview
Generic modal shell for rich content, forms, and structured workflows. Use the more specialised components when the content fits their narrower contract.
See the dialog preset boundary for the durable distinction from ConfirmDialog and AlertDialog.
Choosing this component
- Presenting rich or structured content (forms, multi-step wizards, detail views) inside a blocking overlay.
- Collecting structured input — especially when multiple fields or widgets are involved.
- Displaying content that requires user interaction before the page can continue, but where the interaction is more than a simple yes/no.
Choosing something else
- Two-action confirm/cancel prompts — use
ConfirmDialoginstead. It handles autofocus on the cancel button,aria-describedby, and the destructive-variant button automatically. - Urgent blocking acknowledgements that must not be dismissed by Escape or backdrop click — use
AlertDialoginstead. - Navigation — use a page transition or router link instead. Modals break the browser's back-button mental model.
- Persistent side content — use a
DrawerorSidebarso the content stays visible while the user works. - Displaying information that does not require a decision — use a
Popoveror inline content instead.
Dialog model
Cinder provides three dialog-level components with distinct interaction contracts.
Modal is the generic shell. It handles focus capture/restore, body scroll lock, Escape dismissal, backdrop dismissal, and an optional close button. All three dismissal affordances are on by default (dismissOnBackdropClick, dismissOnEscape, closeButtonVisible). Use Modal when the content is richer than a simple prompt.
ConfirmDialog is a preset for user-initiated binary decisions. It composes Modal + two Buttons, defaults focus to the cancel button (the industry-standard guard against accidental destructive confirms), and wires aria-describedby automatically. Escape, backdrop click, and the close-X all fire onCancel. Use it for "Delete account?", "Discard changes?", and similar two-action prompts.
AlertDialog is a preset for urgent, blocking acknowledgements. It renders Modal with role="alertdialog", dismissOnBackdropClick={false}, dismissOnEscape={false}, and no close button. The user must click an explicit action button to proceed. Use it for session expiry and system-level errors — cases where the system surfaces a condition that must be acknowledged before continuing. For user-initiated actions (even high-impact ones), use ConfirmDialog instead.
The role prop and alertdialog
Modal accepts role="alertdialog" directly. This is intentional: some applications need to compose their own sticky dialog outside the AlertDialog preset (for example, a dialog with richer body content than AlertDialog's plain-text description prop allows).
When composing role="alertdialog" on Modal directly:
- Set
dismissOnBackdropClick={false}anddismissOnEscape={false}— otherwise the urgent-blocking contract is broken. - Set
closeButtonVisible={false}— a close-X contradicts the "must acknowledge" intent. - Pass
describedByIdpointing at a descriptive element in the body —aria-describedbyis required foralertdialog.
If the content fits the plain-text description constraint, prefer AlertDialog over composing Modal + role="alertdialog" manually.
Chrome modes and the title / aria-label contract
Modal's default chrome (chrome="default", the default) renders a header with a visible title, a border, max-width: min(90vw, 32rem), and body padding. chrome="none" renders a chromeless, full-bleed surface instead — no header, border, max-width, or padding — while every coordination guarantee (focus trap, scroll lock, escape-stack participation, the exit-transition lifecycle, role="dialog"/aria-modal) is unchanged.
The two chromes have complementary naming requirements, since the dialog must always have an accessible name:
chrome="default"requirestitle— it's rendered as the visible heading and used as the accessible name viaaria-labelledby.chrome="none"requiresaria-labelinstead — no header renders, so there's nothing to pointaria-labelledbyat.
This conditional requirement is expressed in the generated JSON Schema below via a modal-specific allOf branch (see generate-component-schema.ts's applyModalSchemaRules-equivalent block) — the schema's flat required list can't show it directly, since a discriminated union doesn't collapse into one unconditional list. A dev-time warning fires if either chrome renders without its required name source.
Related components
ConfirmDialog— pre-wired confirm/cancel variant built on Modal. Use for binary decisions.AlertDialog— sticky alert dialog that cannot be dismissed by Escape or backdrop click. Use for urgent acknowledgements.Drawer— side-anchored overlay for supplementary content.Drawerwithplacement="bottom"— bottom-anchored overlay for mobile-style interactions.Popover— non-blocking floating panel for contextual content.
Usage
When to use
- Presenting rich or structured content that requires user interaction before returning to the page — forms, multi-step wizards, detail views.
- Collecting structured input (forms, multi-field workflows) inside an overlay.
- Only a two-action confirm/cancel prompt is needed — use confirm-dialog instead.
- An urgent blocking acknowledgement is needed — use alert-dialog instead.
- Showing side-anchored navigation or settings — use a drawer instead.
- Presenting a small contextual surface anchored to a trigger — use a popover or a bottom-placed drawer instead.
Examples
Basic modal
A modal used as a generic content shell — in this case an invite form with structured fields.
Chromeless (full-bleed) modal
chrome="none" suppresses the header, title, border, max-width, and body padding while keeping the focus trap, scroll lock, escape handling, and exit-transition lifecycle unchanged. aria-label supplies the accessible name since no visible title renders.
Overflowing modal
A modal body with enough content to scroll.
Modal with form
A modal whose first input carries autofocus; the native dialog focuses it on open.
Props
Name | Type | Default | Description |
|---|---|---|---|
open bindable | boolean | false | Controls whether the modal is open; bindable for controlled usage. |
title | text | Text rendered as the modal's visible heading and used as its accessible label. | |
chrome | 'default' | 'default' | Chrome mode. 'default' renders the header, visible title, border,
max-width: min(90vw, 32rem), and body padding. 'none' renders a
chromeless, full-bleed surface — the header, title, border, max-width, and
padding are all suppressed, but coordination (focus trap, scroll lock,
escape-stack participation, the exit-transition lifecycle, role="dialog"
and aria-modal) is entirely unchanged. Default 'default'. |
role | discriminated-union | 'dialog' | ARIA role applied to the underlying dialog element. Default dialog. |
dismissOnBackdropClick | boolean | true | When true, clicking the backdrop outside the modal panel dismisses it. Default true. |
dismissOnEscape | boolean | true | When true, pressing Escape dismisses the modal. Default true. |
closeButtonVisible | boolean | true | When true, renders the close button in the upper corner of the modal panel. Default true. |
children required | snippet | ||
footer | snippet | ||
triggerRef | HTMLElement | null | null | |
describedById | text | When set, applied as aria-describedby on the underlying <dialog>. Pass a short, plain description ID only. | |
onDismiss | () => void | Fired on user-initiated dismissal. Includes: Escape key (native dialog 'cancel' event), backdrop click, and the close-X button. EXCLUDES: parent-driven open = false. Callbacks are not awaited and thrown callbacks do not block close. | |
onExitComplete | () => void | Fired once the exit transition genuinely finishes and the panel actually
unmounts — not when open first flips false. Modal keeps its children
mounted for the whole exit-transition window (via SlidingDialogState),
so a CONSUMER that composes Modal and wraps it in its own {#if} keyed
directly on the same condition that flips open false would destroy the
whole component instance before this exit ever gets a chance to play.
Use this callback to decouple that wrapping condition from the live open
state: keep the composing consumer's own mount gate true until this
fires, then clear it. Fires immediately (no animation) when
prefers-reduced-motion: reduce collapses the transition duration to
zero. Does NOT fire if open flips back to true before the exit
transition completes (a reopen-during-close) — the panel never actually
unmounts in that case, so the composing consumer's mount gate must stay
on. Same pattern as PopoverProps/SelectionPopoverProps'
onExitComplete (CIN-376). |