Dialog
Dialog is a controlled modal with portal rendering, focus trapping, scroll locking, and accessible escape routes built in.
Examples
Confirming a destructive action
The footer is where actions belong — the cancel path first, the destructive one last.
Blocking accidental dismissal
Turn off closeOnEsc and closeOnOverlayClick only when losing the dialog would lose work or
skip a decision. Never remove every close path: the × renders whenever onClose is set.
When to use
Use a Dialog for a short, focused decision that must be resolved before the user moves on: confirming a deletion, naming a new resource, reviewing terms.
Reach for something else when:
- The content is long or the task is a side journey — use a Drawer, which slides in without claiming the whole screen.
- You only need to report the outcome of something — use a Toast (transient) or an Alert (persistent, inline).
- The panel is anchored to a control and holds choices — use a Dropdown.
Accessibility
- The panel is
role="dialog"witharia-modal, named bytitleviaaria-labelledby. - Focus moves into the panel on open, is trapped while open, and returns to the element that opened it on close.
- Every close path calls
onClose: the × button, Escape, and a backdrop click. The × renders only whenonCloseis provided, so a dialog is never left without a visible way out. - The × button is named "Close" by default. Pass
closeLabelto translate its accessible name. - Page scroll is locked while the dialog is open, and the panel is portaled to
document.bodyso it escapes anyoverflow: hiddenancestor.
Props
| Name | Type | Required | Description |
|---|---|---|---|
open | boolean | Required | Controls visibility. `true` mounts the overlay + panel; `false` plays the exit then unmounts. |
onClose | () => void | — | Called by every close path (Esc, overlay click, the × button). The × renders only when this is provided. |
closeLabel | string | — | Accessible name for the close button. Default: `"Close"`. |
title | ReactNode | Required | Heading rendered in the panel header and wired as the accessible name via `aria-labelledby`. |
footer | ReactNode | — | Action buttons rendered in the footer. Omitted → no `.lyra-dialog__footer` chrome. |
closeOnEsc | boolean | — | Close on the Escape key. Default `true`. |
closeOnOverlayClick | boolean | — | Close on a click on the overlay backdrop (never on panel content). Default `true`. |
container | HTMLElement | — | Portal host. Defaults to `document.body`. |
children | ReactNode | Required | Body content. |
Plain HTML
Without React, compose the same classes — you own the open/close state, focus and Escape:
<div class="lyra-dialog-overlay">
<div class="lyra-dialog" role="dialog" aria-modal="true" aria-labelledby="dialog-title">
<div class="lyra-dialog__header">
<h2 class="lyra-dialog__title" id="dialog-title">Delete project</h2>
<button class="lyra-dialog__close" aria-label="Close">✕</button>
</div>
<div class="lyra-dialog__body">This action cannot be undone.</div>
<div class="lyra-dialog__footer">
<button class="lyra-btn lyra-btn--ghost lyra-btn--md">Cancel</button>
<button class="lyra-btn lyra-btn--danger lyra-btn--md">Delete</button>
</div>
</div>
</div>