Dialog
A centered modal. A trigger opens a portalled surface in the middle of the viewport that traps focus and blurs and dims the page behind it — composed from a header, a scrollable body, and a pinned footer. Reach for it for a focused form or an open-ended task; for an edge-docked panel, use Sheet.
Basic
A Dialog.Trigger opens the Dialog.Content. Compose the trigger from a Button through render so it inherits the button's look, hover, and focus; the content holds a Dialog.Header (with a Title, Description, and a built-in Dialog.CloseButton), a scrollable Dialog.Body, and a Dialog.Footer whose actions close the panel through Dialog.Close.
"use client";
import { Button, Dialog } from "@stridge/noctis";
export default function DialogBasic() {
return (
<Dialog.Root>
<Dialog.Trigger render={<Button variant="outline">Open dialog</Button>} />
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>Confirm changes</Dialog.Title>
<Dialog.Description>Review and confirm before you continue.</Dialog.Description>
<Dialog.CloseButton />
</Dialog.Header>
<Dialog.Body>
<p className="text-regular text-muted">
Dialogs sit centered over a blurred, dimming scrim and trap focus while open. Close from the corner
button, the footer, Escape, or a click on the backdrop.
</p>
</Dialog.Body>
<Dialog.Footer>
<Dialog.Close render={<Button variant="secondary">Cancel</Button>} />
<Dialog.Close render={<Button variant="primary">Confirm</Button>} />
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
);
}
Sizes
Set the dialog's maximum width with size on Dialog.Content — sm, md (the default), lg, or full. The panel hugs its content vertically and caps its width by size, with a min-width floor so a small dialog never collapses below a readable width; it shrinks to fit the viewport on narrow screens and scrolls its body if the content runs tall. full lifts the width cap for a near-fullscreen, region-inset panel.
"use client";
import { Button, Dialog } from "@stridge/noctis";
const SIZES: { size: Dialog.Size; label: string }[] = [
{ size: "sm", label: "Small" },
{ size: "md", label: "Medium" },
{ size: "lg", label: "Large" },
{ size: "full", label: "Full" },
];
export default function DialogSizes() {
return (
<div className="flex flex-wrap gap-3">
{SIZES.map(({ size, label }) => (
<Dialog.Root key={size}>
<Dialog.Trigger render={<Button variant="outline">{label}</Button>} />
<Dialog.Content size={size}>
<Dialog.Header>
<Dialog.Title>{label} dialog</Dialog.Title>
<Dialog.Description>
{size === "full"
? "Fills the viewport within its region insets."
: `Width caps at the ${size} extent.`}
</Dialog.Description>
<Dialog.CloseButton />
</Dialog.Header>
<Dialog.Body>
<p className="text-regular text-muted">
The dialog hugs its content vertically and caps its width by size (a min-width floor keeps a small
dialog readable), scrolling its body if the content runs tall. The full size lifts the width cap
for dense, near-fullscreen flows.
</p>
</Dialog.Body>
<Dialog.Footer>
<Dialog.Close render={<Button variant="secondary">Close</Button>} />
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
))}
</div>
);
}
A focused form
Because the dialog traps focus and locks page scroll, it is a natural host for a short form. Put the fields in the Dialog.Body and the submit/cancel actions in the Dialog.Footer — each Dialog.Close dismisses the panel after its action runs. On a real submit, keep the dialog open and surface any error in place rather than closing optimistically.
"use client";
import { Button, Dialog, Input } from "@stridge/noctis";
import { useRef } from "react";
export default function DialogForm() {
const emailRef = useRef<HTMLInputElement>(null);
return (
<Dialog.Root>
<Dialog.Trigger render={<Button variant="primary">Invite member</Button>} />
{/* initialFocus lands on the email field — not the corner close — the considered first stop for a form (APG). */}
<Dialog.Content initialFocus={emailRef}>
<Dialog.Header>
<Dialog.Title>Invite a member</Dialog.Title>
<Dialog.Description>They will receive an email to join the workspace.</Dialog.Description>
<Dialog.CloseButton />
</Dialog.Header>
<Dialog.Body>
<form id="invite" className="flex flex-col gap-2">
<label htmlFor="invite-email" className="text-small text-secondary">
Email address
</label>
<Input.Root>
<Input.Control
id="invite-email"
ref={emailRef}
name="email"
type="email"
aria-label="Email address"
placeholder="teammate@example.com"
/>
</Input.Root>
</form>
</Dialog.Body>
<Dialog.Footer>
<Dialog.Close render={<Button variant="secondary">Cancel</Button>} />
{/* On a real submit, keep the dialog open and show an error in place rather than closing optimistically. */}
<Dialog.Close
render={
<Button variant="primary" type="submit" form="invite">
Send invite
</Button>
}
/>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
);
}
Initial focus
Where focus lands when a dialog opens is a deliberate choice, not an accident. The WAI-ARIA APG calls for a considered initial focus: a form dialog should focus its first field so the user starts typing immediately, rather than landing on the corner close. Pass initialFocus (a Base UI Dialog.Popup prop, forwarded through Dialog.Content) a ref to the element to focus; finalFocus likewise controls where focus returns on close — it returns to the trigger by default. Focus is trapped within the panel while open and restored on close.
"use client";
import { Button, Dialog, Input } from "@stridge/noctis";
import { useRef } from "react";
export default function DialogFocus() {
const nameRef = useRef<HTMLInputElement>(null);
return (
<Dialog.Root>
<Dialog.Trigger render={<Button variant="outline">Rename project</Button>} />
{/*
* Considered initial focus (WAI-ARIA APG): a form dialog focuses its first field, so the user
* starts typing immediately instead of landing on the corner close. Pass `initialFocus` a ref to
* the element to focus; `finalFocus` (a Popup prop) likewise controls where focus returns on close
* (it returns to the trigger by default).
*/}
<Dialog.Content initialFocus={nameRef}>
<Dialog.Header>
<Dialog.Title>Rename project</Dialog.Title>
<Dialog.Description>Focus lands on the name field, ready for input.</Dialog.Description>
<Dialog.CloseButton />
</Dialog.Header>
<Dialog.Body>
<form id="rename" className="flex flex-col gap-2">
<label htmlFor="project-name" className="text-small text-secondary">
Project name
</label>
<Input.Root>
<Input.Control
id="project-name"
ref={nameRef}
name="name"
type="text"
aria-label="Project name"
defaultValue="Untitled"
/>
</Input.Root>
</form>
</Dialog.Body>
<Dialog.Footer>
<Dialog.Close render={<Button variant="secondary">Cancel</Button>} />
<Dialog.Close
render={
<Button variant="primary" type="submit" form="rename">
Save
</Button>
}
/>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
);
}
Scrolling long content
Tall content scrolls one of two ways, set with scroll on Dialog.Content. The default, scroll="body", scrolls the inner Dialog.Body so the header and footer stay pinned. scroll="viewport" hands the overflow to an overlay-level scroll container (Dialog.Viewport): the whole panel scrolls inside the scrollable overlay, staying centered when it fits and growing past the viewport without the page scrolling. Either way, a scroll-boundary cue fades a shadow onto a clipped edge so it always reads as more content above or below.
"use client";
import { Button, Dialog } from "@stridge/noctis";
const PARAGRAPHS = Array.from({ length: 12 }, (_, i) => i + 1);
function LongBody() {
return (
<>
{PARAGRAPHS.map((n) => (
<p key={n} className="text-regular text-muted">
Section {n}. Long-form content scrolls between a pinned header and footer. A scroll-boundary cue fades a
shadow onto the clipped edge so it always reads as more content above or below.
</p>
))}
</>
);
}
export default function DialogScrollable() {
return (
<div className="flex flex-wrap gap-3">
<Dialog.Root>
<Dialog.Trigger render={<Button variant="outline">Body scroll</Button>} />
<Dialog.Content scroll="body">
<Dialog.Header>
<Dialog.Title>Terms of service</Dialog.Title>
<Dialog.Description>The body scrolls; the header and footer stay put.</Dialog.Description>
<Dialog.CloseButton />
</Dialog.Header>
<Dialog.Body className="flex flex-col gap-3">
<LongBody />
</Dialog.Body>
<Dialog.Footer>
<Dialog.Close render={<Button variant="secondary">Decline</Button>} />
<Dialog.Close render={<Button variant="primary">Accept</Button>} />
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
<Dialog.Root>
<Dialog.Trigger render={<Button variant="outline">Viewport scroll</Button>} />
<Dialog.Content scroll="viewport">
<Dialog.Header>
<Dialog.Title>Terms of service</Dialog.Title>
<Dialog.Description>
The whole panel scrolls inside the overlay, centered when it fits.
</Dialog.Description>
<Dialog.CloseButton />
</Dialog.Header>
<Dialog.Body className="flex flex-col gap-3">
<LongBody />
</Dialog.Body>
<Dialog.Footer>
<Dialog.Close render={<Button variant="secondary">Decline</Button>} />
<Dialog.Close render={<Button variant="primary">Accept</Button>} />
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
</div>
);
}
Nested dialogs
A dialog can open another dialog over it. Base UI tracks the depth, and the backgrounded panel recedes — it scales back and dims behind the new layer — so the stack reads as depth rather than a flat pile. Both motions respect prefers-reduced-motion: the layer still recedes and dims, only the animation drops.
"use client";
import { Button, Dialog } from "@stridge/noctis";
export default function DialogNested() {
return (
<Dialog.Root>
<Dialog.Trigger render={<Button variant="outline">Edit profile</Button>} />
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>Edit profile</Dialog.Title>
<Dialog.Description>Update your details, or remove the account entirely.</Dialog.Description>
<Dialog.CloseButton />
</Dialog.Header>
<Dialog.Body>
<p className="text-regular text-muted">
Opening a dialog over this one recedes this panel — it scales back and dims behind the new layer, so the
stack reads as depth.
</p>
</Dialog.Body>
<Dialog.Footer>
<Dialog.Close render={<Button variant="secondary">Cancel</Button>} />
{/* A nested dialog: Base UI tracks the depth, and the layer behind recedes. */}
<Dialog.Root>
<Dialog.Trigger render={<Button variant="danger">Delete account</Button>} />
<Dialog.Content size="sm">
<Dialog.Header>
<Dialog.Title>Delete this account?</Dialog.Title>
<Dialog.Description>
This permanently removes the account. This cannot be undone.
</Dialog.Description>
</Dialog.Header>
<Dialog.Footer>
<Dialog.Close render={<Button variant="secondary">Cancel</Button>} />
<Dialog.Close render={<Button variant="danger">Delete</Button>} />
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
);
}
Anatomy
Compose a dialog from its parts. Dialog.Root owns the open state and surfaces Base UI's dismiss controls — it accepts every Dialog.Root prop, including open, defaultOpen, onOpenChange, modal (true | 'trap-focus'), and disablePointerDismissal (turn off backdrop-click dismissal). Modal by default: focus is trapped, the page is scroll-locked, and the rest of the document is inert.
Dialog.Root— owns the open state; renders no element of its own. SurfacesmodalanddisablePointerDismissal.Dialog.Trigger— opens the dialog. Style it directly or compose aButtonthroughrender.Dialog.Content— the common composition: portal, backdrop, optional viewport, and centered panel in one. Props:size(defaultmd),scroll(bodydefault, orviewport),dismissable(defaulttrue— setfalseto suppress the cornerCloseButton),backdropClassName, and the forwardedinitialFocus/finalFocus. Reach forDialog.Portal+Dialog.Backdrop+Dialog.Popupdirectly only when you need to customize the portal or backdrop wiring.Dialog.Viewport— the overlay-level scroll container forscroll="viewport".Dialog.Contentrenders it for you; reach for it directly only in a hand-built composition.Dialog.Popup— the centered panel surface. SetinitialFocus/finalFocushere to control where focus lands and returns, andelevation(defaultelevated) to retheme the scope.Dialog.Header— the panel's top region. A gutter row: its first column stacks theTitleover theDescription, and a reserved inline-end gutter holds the corner action, so a long title never runs under the close. Separated from the body by a divider.Dialog.Body— the scrollable middle region; it grows to fill and scrolls its overflow so the header and footer stay put.Dialog.Footer— the panel's bottom region, pinned to the base, typically holding the primary and secondary actions. It stacks on small screens and lays out in a row at the inline-end from40remup; it comfortably holds two or three actions (place the primary at the inline-end, cancel before it).Dialog.Title+Dialog.Description— the panel's accessible name and supporting copy, linked to the popup viaaria-labelledbyandaria-describedby.Dialog.CloseButton— the built-in corner close: a ghost iconButtonwith a localizedaria-label, dropped into the header gutter (no absolute positioning). Defaults its glyph to anXand its name to the translated "Close"; passchildrenoraria-labelto override, ordismissable={false}onDialog.Contentto suppress it.Dialog.Close— closes the nearest dialog. Renders a bare button with no styling of its own, so it composes with anyButtonthroughrender— a secondary or primary button for a footer action. Give it anaria-label(or visible text) for the accessible name.
Every rendered part carries a data-slot (noctis-dialog-trigger, noctis-dialog-backdrop, noctis-dialog-viewport, noctis-dialog-popup, noctis-dialog-close, noctis-dialog-header, noctis-dialog-body, noctis-dialog-footer, noctis-dialog-title, noctis-dialog-description) for host-side styling — the popup also carries data-size. Pair it with the Base UI state attributes (data-open, data-closed, data-starting-style, data-ending-style, and data-nested-dialog-open on a backgrounded layer). The popup renders through Surface at elevated elevation, bordered, with the modal shadow, so controls inside re-derive off that base and separate cleanly. The backdrop blurs the page behind it, and the panel enters with a short scale-and-rise that respects prefers-reduced-motion.
Accessibility
- Roles & naming. The popup is
role="dialog"witharia-modal;Dialog.Titlenames it (aria-labelledby) andDialog.Descriptiondescribes it (aria-describedby). For a description holding complex semantics (a list or table), drop theDescriptionso a screen reader doesn't flatten it. - Initial focus. Set
initialFocusto the considered first stop — the first field of a form, or a primary action for an informational dialog. Without it, focus lands on the first tabbable element. Keep a visible close in the tab sequence. - Focus return. Focus returns to the trigger on close; override with
finalFocuswhen the trigger has unmounted (e.g. a deleted row). - Dismissal. Escape always closes the dialog and the backdrop click closes it by default;
disablePointerDismissalturns off the backdrop click for flows that need an explicit choice. KeepDialog.Close/Dialog.CloseButtoninside the popup so touch screen-reader users can escape. - RTL. The header gutter, footer alignment, and corner close use logical properties, so the layout mirrors under RTL by construction.
Keyboard
| Key | Action |
|---|---|
| Enter / Space | On the trigger: open the dialog and move focus into the panel per initialFocus. |
| Tab / Shift + Tab | Move focus to the next / previous element, trapped within the open panel (it wraps). |
| Esc | Close the panel and return focus to the trigger (or finalFocus). |
A click on the dimmed backdrop also closes the dialog (unless disablePointerDismissal). Content behind the panel stays inert and unreachable while the dialog is open. Focus order and directional glyphs mirror under RTL by construction.
Design tokens
Generated from the component's declaration — the same graph that mints the CSS, so a variable name or its resolution default can't drift. The minted tokens are the public override seam: set one on any ancestor and every dialog in that region retunes — e.g. .editor { --noctis-dialog-popup-max-inline-size: 40rem; } widens dialogs beneath it. Knobs that aren't minted are reached through the part's data-slot. See Customization for the full override ladder and Tokens for the whole graph.
API reference
Generated from the component's types — every prop, type, default, and description comes straight from the source. Each part gets its own table; parts that only forward to Base UI's Dialog list just the props they pass through. Expand a row for the full type and description.
Dialog.Root
No props of its own — forwards to the underlying Base UI part.