Dialog Documentation has not yet been reviewed by a human.
Dialog opens content above a dimmed backdrop and keeps focus inside until it
closes. It has optional header and footer regions, a scrollable body, and a close
button. Set position to use an edge drawer.
Playground
Zones
header, children (the body), and footer are the three zones. The body
scrolls when it overflows the available height; the header and footer stay
pinned. Each zone is optional: drop header for a chromeless sheet, or footer
when there are no actions. The footer lays its content out in a right-aligned
row.
<Button data-dialog-open="zones">Open dialog</Button><Dialog name="zones" header={<Title level={4}>Enable telemetry?</Title>} footer={ <> <Button data-dialog-close="zones">Not now</Button> <Button priority="primary" data-dialog-close="zones">Enable</Button> </> }> <Text> Anonymous usage data helps us find the bugs that matter. You can change this any time in settings. </Text></Dialog>Position
position places the dialog. center (the default) is a modal centered on the
viewport, keeping a 32px gap from every edge. left, right, top, and
bottom turn it into an edge drawer: left / right run the full viewport
height and slide in from the side; top / bottom span the full width and slide
down / up. A drawer sits flush against its edge but keeps 32px of overlay on the
far side, so the backdrop stays visible however wide or tall you make it. Drawers
square their corners against the edges they touch. fullscreen fills the whole
viewport: no gap, no radius.
<Dialog position="left" open={open} onStateChange={…}>…</Dialog> {/* full-height left drawer */}<Dialog position="right" open={open} onStateChange={…}>…</Dialog> {/* full-height right drawer */}<Dialog position="top" open={open} onStateChange={…}>…</Dialog> {/* full-width top drawer */}<Dialog position="bottom" open={open} onStateChange={…}>…</Dialog> {/* full-width bottom sheet */}<Dialog position="fullscreen" open={open} onStateChange={…}>…</Dialog> {/* edge-to-edge, no gap */}A drawer’s thickness is the axis it slides along: a left / right drawer’s
width (380px by default), a top / bottom drawer’s height (its content). The
cross axis fills the viewport, so the far-side space is 100vw/100dvh minus
that thickness. Resize it with a position-scoped ::part(dialog) rule (see
Styling).
Rounding corners
The theme sets the centered modal’s default corners. Drawers are square unless
you pass round. A side keyword rounds only that edge’s two corners: a bottom
sheet takes round="top", a right drawer round="left".
<Dialog round={0}>…</Dialog> {/* unrounded centered modal */}<Dialog position="bottom" round="top">…</Dialog> {/* round the sheet's top edge */}<Dialog position="left" round="right">…</Dialog> {/* round a left drawer's inner edge */}<Dialog round={24}>…</Dialog> {/* all corners, 24px */}<Dialog round="12px 12px 0 0">…</Dialog> {/* any border-radius shorthand */}A number or CSS string sets the amount on all corners; the string may be a full
border-radius shorthand, so round="12px 12px 0 0" rounds just the top by 12px
(a side keyword with a custom amount). Bare round rounds all corners, including
on a drawer. For raw <a-dialog>, use a single length (round="20px"). Use the
wrapper’s round prop for shorthands.
Close button
closable controls one thing: whether the top-right ✕ is present (on by
default). The ✕ is just one way to close the dialog, alongside Esc, a backdrop
click, any data-dialog-close / footer action, and your own code. So
closable={false} only removes the ✕ (say, when the footer already carries a
Cancel button); the dialog stays closable through those other paths. The ✕
itself is a keyboard-focusable Button that requests a close like Esc and the
backdrop do.
<Dialog closable={false} header="Rename file" footer={…}>…</Dialog>Dismiss
“Dismissible” means light-dismiss: a backdrop click or Esc closes the dialog,
both on by default. persistent turns only that off. The dialog stays closable
through explicit controls (the ✕ when closable, a footer action, your own
code); persistent doesn’t make it un-closable, it stops an accidental click or
stray Esc from dismissing it. Reach for it on an alert or destructive confirm
that should be answered deliberately.
<Dialog persistent header="Delete account?" footer={ <> <Button onClick={cancel}>Cancel</Button> <Button priority="primary" tone="critical" onClick={confirm}>Delete</Button> </> }> <Text>This permanently removes your account and all its data.</Text></Dialog>A backdrop click dismisses only when the press and the release both land on
the backdrop. Selecting text and releasing outside the dialog won’t close it,
and neither will clicking an option in a Menu or Select whose dropdown
overflows the dialog’s box: that click lands on the popover. So a dialog can
hold interactive content that spills past its edges.
Triggers
Give the dialog a unique name, and any element with data-dialog-open="{name}"
opens it while data-dialog-close="{name}" closes it, with no state or handler.
This helps when the trigger sits far from the dialog in the markup. It’s for
hand-authored or vanilla usage; controlled mode (below) ignores name and you
drive open yourself.
<a-button tabindex="0" data-dialog-open="settings">Settings</a-button>
<a-dialog name="settings"> <h2 slot="header">Settings</h2> <p>…</p> <a-button tabindex="0" slot="footer" data-dialog-close="settings">Done</a-button></a-dialog>Controlled vs uncontrolled
Open state follows Anta’s shared state contract. Leave it uncontrolled
(seed with defaultOpen, or drive it with the name triggers) and the dialog
owns open/close. Pass open + onStateChange to control it: the dialog
follows open only, and every user dismiss (Esc, backdrop, close button)
requests a change that you apply to accept or ignore to reject. The wrapper
takes booleans and the element takes a state string enum; the wrapper maps
between them:
<Dialog> (JSX) |
<a-dialog> (element) |
|
|---|---|---|
| Uncontrolled (element owns it) | defaultOpen / name triggers |
default-state="open" | "closed" / name |
| Controlled (you own it) | open + onStateChange |
state="open" | "closed" + statechange |
| Change event | onStateChange(event, { next, prev }), booleans |
statechange, a CustomEvent<{ next, prev }> of 'open' / 'closed' |
statechange is cancelable and fires before the dialog applies the
change. Uncontrolled, call event.preventDefault() to veto a dismiss (e.g.
confirm before discarding unsaved edits). Controlled, the element never
self-applies; apply detail.next to open to accept, or do nothing to reject.
In controlled mode the element keeps the dialog in sync with your open value,
even against a native close it didn’t route (a <form method="dialog"> submit,
or calling close() on the underlying <dialog>). Such a close re-asserts your
state: the dialog reopens if open is still true. Drive closing through your
state, setting open to false in onStateChange.
const [open, setOpen] = useState(false)
<Button onClick={() => setOpen(true)}>Open dialog</Button><Dialog open={open} onStateChange={(_e, { next }) => setOpen(next)} header={<Title level={4}>Enable telemetry?</Title>} footer={ <> <Button onClick={() => setOpen(false)}>Not now</Button> <Button priority="primary" onClick={() => setOpen(false)}>Enable</Button> </> }> <Text>Anonymous usage data helps us find the bugs that matter.</Text></Dialog>
// uncontrolled, veto the close while there are unsaved changes<Dialog defaultOpen onStateChange={(e, { next }) => { if (!next && unsaved) e.preventDefault()}}>…</Dialog>Focus
On open, focus goes to the dialog itself rather than a control inside it. So
the user Tabs to reach the first field, focus never lands on the ✕ or a
destructive button, and it works the same with or without a close button or
focusable content. A screen reader announces the dialog by its header text
(mirrored to the dialog’s accessible name), and the ✕ is the last tab stop.
To focus a specific control on open instead (a search field, say), pass
autoFocus; the dialog focuses it before returning from the opening interaction.
On close, focus returns to whatever opened the dialog, from the native <dialog>.
<Dialog header="Rename" footer={…}> <Input label="Name" autoFocus /></Dialog> Component props
| Prop | Type | Default | Description |
|---|---|---|---|
| header? | ReactNode | — | Header content: a title, or any node. Rendered in the top zone. Omit for a chromeless dialog with only a body. |
| footer? | ReactNode | — | Footer content, usually the action buttons. Rendered in the bottom zone as a right-aligned row (wraps under pressure). Omit for none. |
| children? | ReactNode | — | The dialog body. Scrolls when it overflows the available height. |
| position? | centerleftrighttopbottomfullscreen | 'center' | Placement. center (the default) is a centered modal; left / right /
top / bottom turn it into an edge drawer (full height for left/right,
full width for top/bottom); fullscreen fills the whole viewport with no
edge gap or corner radius. |
| closable? | boolean | true | Whether the top-right ✕ button is present. It's one way to close the dialog,
alongside Esc, the backdrop, a data-dialog-close / footer action, and your
own code; false removes the ✕ without making the dialog unclosable. |
| persistent? | boolean | — | Turn off light-dismiss: a backdrop click and Esc no longer close the dialog.
It stays closable through explicit controls (the ✕ when closable, a footer
action, your own code); persistent isn't "un-closable", it stops an
accidental click or stray Esc from dismissing. For an alert / confirm that
should be answered deliberately. Omit for the default dismissible behavior. |
| round? | booleannumbertoprightbottomleftstring | — | Corner radius. A side keyword — 'top' / 'right' / 'bottom' / 'left' —
rounds only that edge's two corners (at --dialog-radius), which pairs with
a drawer's exposed edge (a bottom sheet → 'top', a right drawer → 'left').
A number (px) or CSS string rounds all corners at that value; the string
may be a full border-radius shorthand ('12px 12px 0 0'). true rounds all
corners at --dialog-radius. Omit for the position default: center is
rounded, drawers and fullscreen are square. |
| open? | boolean | — | Controlled open state. When provided, the consumer owns open/close: the
dialog only follows this prop, and every user dismiss (Esc, backdrop, close
button) requests a change via onStateChange (reject by not updating).
Leave undefined for uncontrolled. |
| defaultOpen? | boolean | — | Initial open state for the uncontrolled case (read once on mount). |
| name? | string | — | Uncontrolled trigger name. Any element with data-dialog-open="{name}"
opens this dialog, data-dialog-close="{name}" closes it. A convenience for
triggers rendered elsewhere; ignored in controlled mode. |
| onStateChange? | (event, detail) => void | — | Fired before the open state changes, on every user open or dismiss.
event is the cancelable statechange: call event.preventDefault() to
veto an uncontrolled transition. detail.next is the requested open
state, detail.prev the current one (booleans). In controlled mode, apply
detail.next to open to accept, or do nothing to reject. |
Inherited props (className, id, slot, style, tabIndex)
| Prop | Type | Default | Description |
|---|---|---|---|
| className? | string | — | CSS class on the component's root element (merged with the component's own
classes). Use it directly for layout and positioning — grid/flex placement,
margins, alignment — rather than wrapping the component in a <div>/<span>. |
| id? | string | — | HTML id attribute. |
| slot? | string | — | Assigns the element to a named <slot> of a parent web component (e.g.
slot="header" inside a <Dialog>, slot="footer" inside a <Dialog>). |
| style? | CSSProperties | — | Inline styles on the component's root element. Set layout/positioning here
(or via className) directly on the component instead of adding a wrapper. |
| tabIndex? | number | — | Tab order. Set to -1 to skip the element when tabbing. |
Plus every standard DOM event handler (onClick,
onFocus, onKeyDown, …) and any
data-* or aria-* attribute — forwarded as-is to
the underlying <a-*> element.
Web Component
Use the web component directly when you are not using React or Preact and a native control does not fit.
Slots hold the header, footer, and close control. JSX uses boxless
a-dialog-footer and a-dialog-close groups. Raw authors can use those groups
or assign controls directly to the slots. Existing slot markup remains supported.
The element owns the shadow-native dialog and its runtime state. The native
<dialog> supplies top-layer placement, backdrop, focus containment, focus
restoration, and Escape handling. It escapes ancestor clipping and stacking
contexts. The element leaves host attributes and authored children unchanged. Load the element stylesheet
with the granular entry or load bundle.css with the full bundle. Default
layout and transitions require no injected styles or fixed inline styles.
Load CSS before opening a dialog, including one with defaultOpen. Without it,
the native modal remains visible and closable, but Anta’s layout, boxless region
groups, close-control placement, and animations are unavailable.
Application-supplied style props and custom round amounts can still produce
style attributes blocked by style-src-attr 'none'.
Add tabindex="0" to every raw <a-button>.
<a-button role="button" tabindex="0" data-dialog-open="invite"><a-button-label>Invite teammates</a-button-label></a-button><a-dialog name="invite"> <a-title slot="header" level="4" role="heading" aria-level="4">Invite teammates</a-title> <a-text>Send an invite link to your team.</a-text> <a-button slot="footer" role="button" tabindex="0" data-dialog-close="invite"><a-button-label>Cancel</a-button-label></a-button> <a-button slot="footer" role="button" tabindex="0" priority="primary" data-dialog-close="invite"><a-button-label>Send</a-button-label></a-button> <a-button slot="close" role="button" tabindex="0" priority="tertiary" size="large" aria-label="Close" data-custom-event="closerequest"><a-icon shape="x" aria-hidden="true"></a-icon></a-button></a-dialog> Styling
Reach for the props first: position (modal, drawer edge, or fullscreen),
closable (the ✕), persistent (light-dismiss off), round (corner
radius). For everything else, style the shadow parts in plain CSS.
<a-dialog> exposes ::part(dialog) (the surface), ::part(header),
::part(body), ::part(footer), ::part(close), and ::part(dialog)::backdrop
(the overlay). Defaults live in external CSS. An unlayered consumer rule
overrides them with no
!important, so the surface, size, radius, shadow, overlay, and zone padding are
all reachable. Keep visibility and motion overrides scoped to
::part(dialog):modal; setting display on the base part can reveal a closed
dialog. The native dialog controls closing independently of CSS transitions.
Entry and exit effects follow the browser’s existing @starting-style, display,
and overlay-transition support; reduced motion disables Anta’s transitions.
Surface fades require ::part(dialog):open support; otherwise the surface stays
visible without fading. Backdrop fades separately require
::part(dialog):modal::backdrop, keeping static styling when unavailable.
Defaults belong to anta.components, within the layer order
base, anta, components, utilities. Put consumer overrides in components,
utilities, or unlayered CSS. Overrides in the earlier base layer lose to
Anta’s defaults. Previously, outer part rules took precedence over the internal
shadow stylesheet regardless of their layer.
Optional regions expose CSS states on the host without adding attributes or changing part names. Each state is present when its slot has assigned nodes:
| State selector | Assigned slot |
|---|---|
a-dialog:state(has-header) |
header |
a-dialog:state(has-footer) |
footer |
The header, or the body when no header is present, reserves 52px of inline-end
padding for a close control. The example increases the header clearance to
64px when :has(> [slot="close"]) matches. This selector reads the authored
close-slot markup directly. Firefox can leave clearance stale when an existing
child’s slot attribute changes into or out of close; adding or removing a
close child updates normally.
Scope the rule to a position (or a class). A bare a-dialog::part(dialog)
hits every dialog, drawers included, where a width override would collapse a
bottom sheet off its full-bleed edge. Target the one you mean: a class for a
specific dialog, [position="right"] for a drawer edge, :not([position]) for
the centered modal (center carries no position attribute).
/* one dialog, via a class — nothing else is touched */a-dialog.styled-dialog::part(dialog) { width: 640px; border-radius: 20px; background: var(--bg-2);}a-dialog.styled-dialog::part(dialog)::backdrop { background: color-mix(in oklch, black 60%, transparent);}a-dialog.styled-dialog:has(> [slot="close"])::part(header) { padding-inline-end: 64px;}More recipes. The centered modal’s max size is the viewport minus a 32px edge
gap; set max-width / max-height on the surface to change it. A drawer’s
thickness is the axis it slides along, so scope it to that edge and the cross
axis stays full-bleed.
/* centered modal: size + the viewport edge gap (max-*) */a-dialog:not([position])::part(dialog) { width: 720px; max-width: 90vw; max-height: 85dvh; }
/* a drawer's thickness — scoped to its edge */a-dialog[position="right"]::part(dialog) { width: 480px; }a-dialog[position="bottom"]::part(dialog) { height: 60dvh; }
/* zone padding, shadow, overlay (unscoped → every dialog) */a-dialog::part(header) { padding: 24px 24px 12px; font-size: 20px; }a-dialog::part(footer) { justify-content: space-between; }a-dialog::part(dialog) { box-shadow: 0 20px 60px color-mix(in oklch, black 30%, transparent); }a-dialog::part(dialog)::backdrop { background: color-mix(in oklch, black 50%, transparent); }If you override the surface, shadow, or overlay, add a .dark rule to match —
the built-in defaults track light / dark, a fixed override won’t.