Skip to content
Nizivo UI

Dialog

A modal dialog or alert dialog with a trigger, title, description, body, footer and close parts. Focus moves in on open and returns to the trigger on close.

Client componentEngine: react-aria

Import

import { Dialog } from '@nizivo/react/dialog';

// Once per app (Getting started), after
// @nizivo/tokens/tokens.css and @nizivo/react/css/base.css and @nizivo/react/css/icon-button.css
import '@nizivo/react/css/dialog.css';

Static or interactive

Client. It always ships React Aria's dialog and overlay code (+61,402 B gzip under Turbopack for one Dialog on the Next.js fixture). The default Dialog.Close writes the IconButton markup itself, so it adds no Tooltip code. How to choose.

The trigger

Compose a Button (or an IconButton) into the trigger; it is still the recommended look:

<Dialog.Trigger render={<Button variant="secondary">Edit</Button>} />

A Button is 32, 40 or 48 px tall and brings the variants, the focus ring and the pressed state.

A bare <Dialog.Trigger>Edit</Dialog.Trigger> is a plain, unstyled <button>, but it has a minimum size of its own: at least 24 by 24 CSS pixels, the minimum target of WCAG 2.2 success criterion 2.5.8 (Target Size, Minimum), so two bare triggers side by side, or a lone glyph, still meet it. It also carries nz-hit-target, so under a coarse pointer it gets the same 44 px hit area as every standalone pressable. A trigger you render yourself through render keeps its own size and gets neither.

A trigger inside running text, such as a word in a sentence that opens a dialog, is the exception: render your own element through render there. The 24 px minimum would make its line taller than the others, and under a coarse pointer the 44 px hit area would reach into the lines above and below and take taps meant for a link there. WCAG 2.2 2.5.8 exempts a target inside a sentence, so the inline element needs neither.

The close button

<Dialog.Close /> with no render and no children is an IconButton (tertiary, md) showing the close glyph, named "Close". Change the name with labels, or pass aria-label:

<Dialog.Popup>
  <Dialog.Title>Edit product</Dialog.Title>
  <Dialog.Close labels={{ close: 'Fermer' }} />
  <Dialog.Body>…</Dialog.Body>
</Dialog.Popup>

Placed as a direct child of Dialog.Popup, it sits in the popup's top end corner, 20 px from both edges, where Figma draws the header's close glyph, and the title leaves room for it. Its focus ring always shows, because it is positioned against the popup, which never clips. Its markup is IconButton's, so a page that imports per-component stylesheets needs css/icon-button.css for it too (the stylesheets list above names it; styles.css already has it).

With children, it is a plain button with that text; with render, it is your element, for example render={<Button>Cancel</Button>} in the footer, or another glyph through render={<IconButton icon={<X />} label="Close" tooltip={false} />}. Either one removes the default glyph. A control placed directly in the content and flush with its edge draws its ring inside itself, so the content's corner clip never cuts the ring.

Focus return

When the dialog closes, focus goes, in order, to:

  1. returnFocus on Dialog.Root, when it is an element ref or a function that returns an element that is still on the page;
  2. the trigger, or the element that had focus when the dialog opened;
  3. the trigger of the overlay the dialog was opened from: a Dialog opened from a Menu item returns focus to the Menu button, after the Menu has closed;
  4. <body>, and development builds warn in the console, naming returnFocus. Production builds drop the warning.

returnFocus={false} leaves focus wherever the browser puts it, for an app that moves focus itself. Focus that already went somewhere (an outside click on another control) is never moved.

const search = useRef<HTMLInputElement>(null);

<Dialog.Root open={open} onOpenChange={setOpen} returnFocus={search}>
  …
</Dialog.Root>;

Examples

Basic

Figma Modal Type=Destructive: a server Button arrives as the trigger through render. Compose a Button (or an IconButton) into Dialog.Trigger rather than styling the bare part: it brings the control look and the 40 px control height (the bare part has only the WCAG 2.2 2.5.8 minimum of 24 x 24 px, and the 44 px hit area under a coarse pointer; M27). The bare Dialog.Close is the header's close IconButton, named "Close"; the alertdialog still focuses Cancel, its least destructive action.

import type { JSX } from 'react';
import { Button } from '@nizivo/react/button';
import { Dialog } from '@nizivo/react/dialog';

export function DialogExample(): JSX.Element {
  return (
    <Dialog.Root>
      <Dialog.Trigger render={<Button variant="critical">Delete product</Button>} />
      <Dialog.Popup role="alertdialog" size="sm">
        <Dialog.Title>Delete “Stoneware mug”?</Dialog.Title>
        <Dialog.Close />
        <Dialog.Description>This can't be undone.</Dialog.Description>
        <Dialog.Footer>
          <Dialog.Close render={<Button>Cancel</Button>} />
          <Button type="submit" variant="critical">
            Delete
          </Button>
        </Dialog.Footer>
      </Dialog.Popup>
    </Dialog.Root>
  );
}

Open

Open on the server: closed overlays and server-rendered opens both emit no popup HTML (§12.2 SSR). The trigger is a composed Button, as everywhere.

Source only

It opens on page load (defaultOpen), which would move focus on this page, so it is shown as source only.
import type { JSX } from 'react';
import { Button } from '@nizivo/react/button';
import { Dialog } from '@nizivo/react/dialog';

export function DialogOpenExample(): JSX.Element {
  return (
    <Dialog.Root defaultOpen>
      <Dialog.Trigger render={<Button variant="secondary">Edit</Button>} />
      <Dialog.Popup>
        <Dialog.Title level={3}>Edit product</Dialog.Title>
        <Dialog.Close />
        <Dialog.Body>Body</Dialog.Body>
      </Dialog.Popup>
    </Dialog.Root>
  );
}

Plain Close

A footer-less dialog closed by a full-width Dialog.Close flush with the popup's bottom edge. The content clips its corners (Figma), so a control placed straight in it draws its keyboard ring inside itself, where the clip cannot cut it (§12.3).

Source only

It opens on page load (defaultOpen), which would move focus on this page, so it is shown as source only.
import type { JSX } from 'react';
import { Button } from '@nizivo/react/button';
import { Dialog } from '@nizivo/react/dialog';

export function DialogPlainCloseExample(): JSX.Element {
  return (
    <Dialog.Root defaultOpen>
      <Dialog.Trigger render={<Button variant="secondary">Details</Button>} />
      <Dialog.Popup>
        <Dialog.Title level={3}>Order shipped</Dialog.Title>
        <Dialog.Body>It arrives on Friday.</Dialog.Body>
        <Dialog.Close>Done</Dialog.Close>
      </Dialog.Popup>
    </Dialog.Root>
  );
}

Parts

Use the parts through the namespace (Dialog.Root) or by their flat names (DialogRoot); both work from Server Components.

Dialog.Root

Client component Renders no element of its own. Props: DialogRootProps. Source: src/dialog/dialog.client.tsx.

NameTypeDefaultDescription
childrenrequiredReactNode–
defaultOpenboolean–
onOpenChange(open: boolean, details: ChangeDetails<OpenReason>) => void–

details.reason is 'unknown' with React Aria, which reports no cause.

openboolean–
returnFocusReturnFocusTargettrue

Where focus goes when the dialog closes (§12.2 Focus return). true returns it to the trigger (the element that opened the dialog), else to the trigger of the overlay it was opened from (a Menu button, after a Menu item opened it), else it stays on <body> and development builds warn. An element ref, or a function returning an element, goes first while that element is on the page; false leaves focus where the browser puts it, for an app that moves it itself.

Dialog.Trigger

Without render, its own <button>: nz-hit-target gives it the 44 px coarse-pointer hit area of every standalone pressable, and dialog.css gives that bare button a 24 x 24 px minimum (WCAG 2.2 2.5.8; M27). A composed Button or IconButton keeps its own size and classes.

Client component Renders data-slot="dialog-trigger" with the class nz-dialog-trigger. Props: DialogTriggerProps. Source: src/dialog/dialog.client.tsx.

NameTypeDefaultDescription
childrenReactNode–
renderRenderProp<DialogTriggerState>–

Must render a <button>, for example render={<Button variant="critical">Delete</Button>}.

  • Also takes every attribute of <button> except children. (Omit<ComponentPropsWithRef<"button">, "children">)

render takes an element, or, in client code, a function of the props and this state (DialogTriggerState):

NameTypeDefaultDescription
disabledrequiredboolean–
focusVisiblerequiredboolean–
openrequiredboolean–
pressedrequiredboolean–

Dialog.Popup

Client component Renders data-slot="dialog-popup" with the class nz-dialog-popup. Props: DialogPopupProps. Source: src/dialog/dialog.client.tsx.

NameTypeDefaultDescription
backdropboolean–

The scrim element. Defaults to on for the root modal and off for nested modals.

childrenrequiredReactNode–
containerElement | null–

Portal container. Defaults to the parent overlay, else document.body (§12.1 L2).

roleDialogRole
"dialog""alertdialog"
'dialog'

alertdialog disables outside-press dismissal.

sizeDialogSize
"sm""md""lg"
'md'
  • Also takes every attribute of <div> except children, role. (Omit<ComponentPropsWithRef<"div">, "children" | "role">)

Dialog.Title

Client component Renders data-slot="dialog-title" with the class nz-dialog-title. Props: DialogTitleProps. Source: src/dialog/dialog.client.tsx.

NameTypeDefaultDescription
levelDialogTitleLevel
23456
2
renderRenderProp<Record<string, never>>–
  • Also takes every attribute of <h2> except id. (Omit<ComponentPropsWithRef<"h2">, "id">)

render takes an element, or, in client code, a function of the props and no state.

Dialog.Description

Client component Renders data-slot="dialog-description" with the class nz-dialog-description. Props: DialogDescriptionProps. Source: src/dialog/dialog.client.tsx.

NameTypeDefaultDescription
renderRenderProp<Record<string, never>>–
  • Also takes every attribute of <p> except id. (Omit<ComponentPropsWithRef<"p">, "id">)

render takes an element, or, in client code, a function of the props and no state.

Dialog.Body

The only scroller inside the popup.

Client component Renders data-slot="dialog-body" with the class nz-dialog-body. Props: DialogBodyProps. Source: src/dialog/dialog.client.tsx.

NameTypeDefaultDescription
renderRenderProp<Record<string, never>>–
  • Also takes every attribute of <div>. (ComponentPropsWithRef<"div">)

render takes an element, or, in client code, a function of the props and no state.

Client component Renders data-slot="dialog-footer" with the class nz-dialog-footer. Props: DialogFooterProps. Source: src/dialog/dialog.client.tsx.

NameTypeDefaultDescription
renderRenderProp<Record<string, never>>–
  • Also takes every attribute of <div>. (ComponentPropsWithRef<"div">)

render takes an element, or, in client code, a function of the props and no state.

Dialog.Close

With render or children, the consumer's button. With neither, an IconButton (tertiary, md) showing the core close glyph (§12.3, §14.2), named by labels.close. Its markup is the IconButton element's (iconButtonElement, icon-button-static.tsx since M31), written out: importing that builder cost the Dialog entry about 280 B gzip against its 31 KiB cap. An overlay-lane case compares the two.

Client component Renders data-slot="dialog-close" with the class nz-dialog-close. Props: DialogCloseProps. Source: src/dialog/dialog.client.tsx.

NameTypeDefaultDescription
childrenReactNode–

Text content, rendered in a plain <button>; leave it out for the default IconButton.

labelsPartial<Pick<DialogLabels, "close">>–

The default IconButton's accessible name; an aria-label you pass wins.

renderRenderProp<DialogCloseState>–

Must render a <button>, for example render={<Button>Cancel</Button>}. With neither render nor children, the part is an IconButton showing the close glyph, named by labels.close, which sits in the popup's top corner when it is a direct child of Dialog.Popup (§12.3).

  • Also takes every attribute of <button> except children. (Omit<ComponentPropsWithRef<"button">, "children">)

render takes an element, or, in client code, a function of the props and this state (DialogCloseState):

NameTypeDefaultDescription
disabledrequiredboolean–
focusVisiblerequiredboolean–
pressedrequiredboolean–

Other exports

DialogBodyProps type

import type { DialogBodyProps } from '@nizivo/react/dialog'

The props of Dialog.Body, listed above.

DialogCloseProps type

import type { DialogCloseProps } from '@nizivo/react/dialog'

The props of Dialog.Close, listed above.

DialogCloseState type

import type { DialogCloseState } from '@nizivo/react/dialog'

The render state of Dialog.Close, listed above.

DialogDescriptionProps type

import type { DialogDescriptionProps } from '@nizivo/react/dialog'

The props of Dialog.Description, listed above.

DialogFooterProps type

import type { DialogFooterProps } from '@nizivo/react/dialog'

The props of Dialog.Footer, listed above.

DialogLabels type

import type { DialogLabels } from '@nizivo/react/dialog'

Strings the Dialog renders (§9.3 rule 7, §16.4). close is the accessible name of the default Dialog.Close (an IconButton showing the close glyph), set through that part's labels. Engine-owned with React Aria (not overridable): dismiss, the visually hidden "Dismiss" button rendered inside a dismissable dialog.

NameTypeDefaultDescription
closerequiredstring'Close'
dismissrequiredstring–
DialogPopupProps type

import type { DialogPopupProps } from '@nizivo/react/dialog'

The props of Dialog.Popup, listed above.

DialogPopupState type

import type { DialogPopupState } from '@nizivo/react/dialog'

NameTypeDefaultDescription
enteringrequiredboolean–
exitingrequiredboolean–
DialogRole type

import type { DialogRole } from '@nizivo/react/dialog'

Figma Modal Type: Default → dialog, Destructive → alertdialog.

type DialogRole = "dialog" | "alertdialog"

DialogRootProps type

import type { DialogRootProps } from '@nizivo/react/dialog'

The props of Dialog.Root, listed above.

DialogSize type

import type { DialogSize } from '@nizivo/react/dialog'

md is Figma's only Modal width (480 px); sm (400) and lg (640) are provisional.

type DialogSize = "sm" | "md" | "lg"

DialogTitleLevel type

import type { DialogTitleLevel } from '@nizivo/react/dialog'

type DialogTitleLevel = 2 | 3 | 4 | 5 | 6

DialogTitleProps type

import type { DialogTitleProps } from '@nizivo/react/dialog'

The props of Dialog.Title, listed above.

DialogTriggerProps type

import type { DialogTriggerProps } from '@nizivo/react/dialog'

The props of Dialog.Trigger, listed above.

DialogTriggerState type

import type { DialogTriggerState } from '@nizivo/react/dialog'

The render state of Dialog.Trigger, listed above.

Data attributes and slots

Style parts by class (.nz-<slot>) and state by data attribute. Attribute meanings come from contracts/vocabulary.json.

SlotRendered byData attributes
dialog-backdrop
.nz-dialog-backdrop
Dialog.Popup
inner element (CSS only)
  • data-entering (present or absent)
    Enter transition running; the node stays mounted until it ends
  • data-exiting (present or absent)
    Exit transition running; the node stays mounted until it ends
  • data-open (present or absent)
    Open
dialog-body
.nz-dialog-body
Dialog.Body
root (takes render)
none
dialog-close
.nz-dialog-close
Dialog.Close
root (takes render); interactive, focus: self
  • data-disabled (present or absent) ← disabled
    Mirrors the disabled attribute
  • data-focus-visible (present or absent)
    Focus is visible; elsewhere use :focus-visible
  • data-pressed (present or absent)
    Pointer or keyboard press (native parts use :active)
dialog-content
.nz-dialog-content
Dialog.Popup
inner element (CSS only)
none
dialog-description
.nz-dialog-description
Dialog.Description
root (takes render)
none
dialog-footer
.nz-dialog-footer
Dialog.Footer
root (takes render)
none
dialog-popup
.nz-dialog-popup
Dialog.Popup
root (no render); forced colours: border
  • data-entering (present or absent)
    Enter transition running; the node stays mounted until it ends
  • data-exiting (present or absent)
    Exit transition running; the node stays mounted until it ends
  • data-nested-open (present or absent)
    A child modal is open
  • data-open (present or absent)
    Open
  • data-size: "sm""md""lg" ← size
    The size prop; values in each component manifest
dialog-positioner
.nz-dialog-positioner
Dialog.Popup
inner element (CSS only)
  • data-entering (present or absent)
    Enter transition running; the node stays mounted until it ends
  • data-exiting (present or absent)
    Exit transition running; the node stays mounted until it ends
  • data-open (present or absent)
    Open
dialog-title
.nz-dialog-title
Dialog.Title
root (takes render)
none
dialog-trigger
.nz-dialog-trigger
Dialog.Trigger
root (takes render); interactive, focus: self
  • data-disabled (present or absent) ← disabled
    Mirrors the disabled attribute
  • data-focus-visible (present or absent)
    Focus is visible; elsewhere use :focus-visible
  • data-open (present or absent)
    Open
  • data-pressed (present or absent)
    Pointer or keyboard press (native parts use :active)
icon-button-icon
.nz-icon-button-icon
Dialog.Close
inner element (CSS only)
none

CSS hooks

Set a hook on the component's class or any ancestor to restyle it without specificity games; leave it unset for the default shown.

HookDefaultRead as
--nz-dialog-bg--nz-bg-surface-raised
  • background on .nz-dialog-popup
--nz-dialog-radius--nz-radius-2xl
  • border-radius on .nz-dialog-popup
--nz-dialog-width
  • default: 30rem
  • data-size="lg": 40rem
  • data-size="sm": 25rem
  • inline-size on .nz-dialog-popup

Layers: nz.components. Classes: .nz-dialog-backdrop, .nz-dialog-body, .nz-dialog-close, .nz-dialog-content, .nz-dialog-description, .nz-dialog-footer, .nz-dialog-popup, .nz-dialog-positioner, .nz-dialog-title, .nz-dialog-trigger, .nz-hit-target, .nz-icon-button.

Tokens this stylesheet reads (28)

--nz-bg-scrim, --nz-bg-subtle, --nz-bg-surface-raised, --nz-border-width-focus, --nz-duration-base, --nz-duration-fade, --nz-duration-spring, --nz-easing-enter, --nz-easing-exit, --nz-easing-spring, --nz-elevation-4, --nz-font-body-l, --nz-font-heading-h3, --nz-icon-secondary, --nz-radius-2xl, --nz-scale-enter, --nz-size-control-md, --nz-size-icon-md, --nz-space-2, --nz-space-3, --nz-space-4, --nz-space-5, --nz-space-6, --nz-space-8, --nz-text-primary, --nz-text-secondary, --nz-tracking-heading-h3, --nz-z-overlay

Accessibility

These parts are interactive. Each gets the focus outline and touch-action rule from @nizivo/react/css/base.css (the nz.base layer), so the floor survives a restyle:

  • dialog-close: draws the focus outline itself on keyboard focus.
  • dialog-trigger: draws the focus outline itself on keyboard focus.

In forced-colours mode these states keep a visible system colour: dialog-popup (border).

Behaviour comes from the react-aria engine through Nizivo's adapter: focus management, ARIA wiring and keyboard handling are the engine's.

Pattern: WAI-ARIA Authoring Practices.

Keyboard

KeysAction
Enter, SpaceOn the trigger (a button), opens the dialog and moves focus to the first focusable element in its body, or to the dialog itself when the body has none. An alertdialog focuses the Close in its footer (its least destructive action), else any Close; a control with autoFocus wins.
Tab, Shift+TabMoves focus between the focusable elements inside the dialog; focus stays inside while it is open.
EscapeCloses the dialog (also an alertdialog) and returns focus to the trigger, or along the returnFocus chain below.

Source: react-aria-components 1.22.1 ModalOverlay and Modal (react-aria 3.53.1 useModalOverlay, which contains focus) through _engines/react-aria/dialog.client.tsx. React Aria focuses the dialog element; the first-focusable rule is Nizivo's (initialFocus on the engine seam, ARCHITECTURE.md §12.2), checked by the overlay lane (test/overlay/dialog.spec.tsx). apps/docs/scripts/smoke.ts checks opening with Enter, Tab staying inside, and Escape closing with focus back on the trigger; the Next fixture checks Escape and the Close part. An alertdialog does not close on an outside press.

Figma

Node ids from design/component-map.json, which nz gen writes from component.json. The links open the Nizivo Figma library, which may ask you to sign in.

On this page