A modal panel on the scrim with a title, optional icon well and pinned action row.
Basic
A float panel centred on the scrim, with 16px clear on every side. From the top: an optional icon well, the title, a description, a body and an action row. Each part shows only when given. The title is required and renders as an h2 in the card title step.
Example
Example
import { Button, Dialog, DialogTrigger, TextField } from '@snapshot-ui/react';
import { Pencil } from 'lucide-react';
import type { ReactElement } from 'react';
export default function DialogBasic(): ReactElement {
return (
<DialogTrigger>
<Button variant="secondary">Rename the workspace</Button>
<Dialog
icon={<Pencil />}
title="Rename the workspace"
description="The new name shows in the header and in every invite."
actions={<Button slot="close">Rename</Button>}
>
<TextField label="Workspace name" defaultValue="Northwind Studio" />
</Dialog>
</DialogTrigger>
);
}Danger
tone="danger" tints the icon well with error, for a question that loses something. It changes only the well. The actions are yours: end the row with a tertiary Cancel, then a danger Button.
Example
Example
import { Button, Dialog, DialogTrigger } from '@snapshot-ui/react';
import { TriangleAlert } from 'lucide-react';
import type { ReactElement } from 'react';
export default function DialogDanger(): ReactElement {
return (
<DialogTrigger>
<Button variant="danger">Delete the workspace</Button>
<Dialog
tone="danger"
icon={<TriangleAlert />}
title="Delete the workspace?"
description="Its 12 projects and their history go with it. This cannot be undone."
actions={
<>
<Button variant="tertiary" slot="close">
Cancel
</Button>
<Button variant="danger" slot="close">
Delete the workspace
</Button>
</>
}
/>
</DialogTrigger>
);
}Long content
A tall body scrolls inside the panel, and the head and action row stay put.
- A scrolling body with nothing to focus, such as a page of terms, is its own Tab stop so the keyboard can scroll it.
hasCloseButtonadds an X in the top end corner, for a long dialog. Escape and the scrim already close it.size="lg"widens the panel from 28rem to 40rem. Both widths are capped at the screen less 2rem.
Example
Example
import { Button, Dialog, DialogTrigger } from '@snapshot-ui/react';
import type { ReactElement } from 'react';
const SECTIONS = [
'Who can see the workspace',
'What members can change',
'How long history is kept',
'What leaves with a member',
'How billing follows the owner',
'When the workspace is archived',
'How an export is made',
'What happens on deletion',
];
export default function DialogLong(): ReactElement {
return (
<DialogTrigger>
<Button variant="secondary">Read the workspace terms</Button>
<Dialog
size="lg"
hasCloseButton
title="Workspace terms"
description="What every member agrees to when they join."
actions={<Button slot="close">I agree</Button>}
>
{SECTIONS.map((section) => (
<p key={section}>
<strong>{section}.</strong> Every workspace keeps its projects, its members and their history, and
the owner decides who joins. A member sees what the owner shares with them, changes what their
role allows, and keeps a copy of nothing once they leave.
</p>
))}
</Dialog>
</DialogTrigger>
);
}Usage
Open a dialog from a DialogTrigger, which holds the button and the dialog. Or control it with isOpen and onOpenChange, or start it open with defaultOpen. Dialog is a client component, and a server component can render it, as the examples here do. To close it, use a button with slot="close", or pass children and actions as functions that receive close.
For input, put a form with an id in children, and its submit button in actions, linked by the button's form attribute. The button and Enter in a field both submit. The form's onSubmit reads the values, does the work and closes the dialog:
'use client';
import { Button, Dialog, DialogTrigger, TextField } from '@snapshot-ui/react';
import { useId } from 'react';
export function RenameWorkspace({ name, onRename }: { name: string; onRename: (name: string) => void }) {
const formId = useId();
return (
<DialogTrigger>
<Button variant="secondary">Rename the workspace</Button>
<Dialog
title="Rename the workspace"
description="The new name shows in the header and in every invite."
actions={
<Button type="submit" form={formId}>
Rename
</Button>
}
>
{({ close }) => (
<form
id={formId}
onSubmit={(event) => {
event.preventDefault();
const next = new FormData(event.currentTarget).get('name');
if (typeof next === 'string') onRename(next);
close();
}}
>
<TextField label="Workspace name" name="name" defaultValue={name} isRequired />
</form>
)}
</Dialog>
</DialogTrigger>
);
}The body and action row are not exported. Everything in children lands in the body, so buttons there scroll away. actions is the one row that stays in view.
- Stacking: a dialog opened from a dialog stacks over it, such as a confirm over the form it guards. One scrim shows, Escape closes the top one, and focus returns to the first.
- Theme: a dialog takes the theme, shape and density of what opened it, so one opened from a dark card is dark on a light page. See Theming.
- Layering: dialogs do not use the browser's top layer.
--z-modal(1000) orders modal surfaces and their scrims, and--z-toast(1100) the toast region above them. Raise both if your app's chrome uses larger numbers. A third-party overlay with a very large z-index can still cover a dialog. - Focus after removal: when the action removes the button that opened the dialog, such as deleting a row, move focus yourself to the next row or the list.
Props
The rest of React Aria's dialog props and ref go on the dialog element, which takes focus. className goes on the panel and overlayClassName on the scrim.
actions ((opts: { close: () => void; }) => ReactNode) | ReactNode | The button row, pinned under the body at the inline end. For a form, put |
|---|---|
children ((opts: { close: () => void; }) => ReactNode) | ReactNode | The body, under the description. It scrolls when it is taller than the viewport allows. |
className string | ((values: ModalRenderProps & { defaultClassName: string | undefined; }) => string) | Goes on the panel, merged after the library's classes. |
closeLabel string | The close button's accessible name. Default: 'Close' |
defaultOpen boolean | |
description ReactNode | A line under the title, in slate. It becomes the dialog's accessible description. |
hasCloseButton boolean | An X in the top end corner. Escape and the scrim already close a dialog, so this is for long ones. Default: false |
icon ReactNode | A 20px icon in a 48px well above the title. |
isDismissable boolean | A press on the scrim closes the dialog. Default: true |
isKeyboardDismissDisabled boolean | Escape no longer closes it. Default: false |
isOpen boolean | Open state, for a dialog without a DialogTrigger. Controlled and uncontrolled. |
onOpenChange ((isOpen: boolean) => void) | |
overlayClassName string | Goes on the scrim. |
size "lg" | "md" | The panel width: Default: 'md' |
title ReactNode | The title, an h2 drawn in the card title step. It names the dialog. Required. |
tone "danger" | "default" |
Default: 'default' |
Accessibility
The dialog is named by its title and described by its description. On open, focus moves to the dialog, or to a control inside that requests it, and Tab stays inside. On close, focus returns to the button that opened it. While open, the page behind is hidden from assistive technology and does not scroll.
| Tab | Cycles through the controls inside. |
|---|---|
| Escape | Closes the dialog. |
| EnterSpace | Press a button. |
Design rules
What keeps dialogs in the system.
Title the dialog with what it does, such as Rename the workspace.
Put the primary action last in the row, after Cancel.
Keep the body to one task.
Never open a dialog on page load. A dialog answers something the person did.
Never stack more than a confirm over a dialog.
Never hide the only way out behind a disabled button.