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

Follows the page theme and corners
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

Follows the page theme and corners
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.
  • hasCloseButton adds 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

Follows the page theme and corners
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 <form id={formId}> in children and <Button type="submit" form={formId}> here. Enter in a field still submits.

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: md is 28rem, lg is 40rem, both capped at the viewport less 2rem.

Default: 'md'

title
ReactNode

The title, an h2 drawn in the card title step. It names the dialog. Required.

tone
"danger" | "default"

danger tints the icon well with error, for a question that loses something. The actions are the caller's: a danger dialog usually ends in a danger Button.

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.

TabCycles through the controls inside.
EscapeCloses the dialog.
EnterSpacePress 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.

esc

↑ ↓ to move↵ to open