Skip to content

Dialog

A window over the page, in the middle or near the top, with a scrim behind it, an x in its corner, and a header, a body and a footer.

import { Button, Dialog, Portal } from '@contour-ui/react';

<Dialog.Root>
  <Dialog.Trigger asChild>
    <Button>Export</Button>
  </Dialog.Trigger>
  <Portal>
    <Dialog.Backdrop />
    <Dialog.Positioner>
      <Dialog.Content>
        <Dialog.CloseTrigger />
        <Dialog.Header>
          <Dialog.Title>Export the mix</Dialog.Title>
          <Dialog.Description>Render the session to an audio file.</Dialog.Description>
        </Dialog.Header>
        <Dialog.Body>…</Dialog.Body>
        <Dialog.Footer>
          <Dialog.CloseTrigger asChild>
            <Button variant="outline">Cancel</Button>
          </Dialog.CloseTrigger>
          <Button onClick={exportMix}>Export</Button>
        </Dialog.Footer>
      </Dialog.Content>
    </Dialog.Positioner>
  </Portal>
</Dialog.Root>;

Dialog is the Headless Dialog with the look of contour-ui: the scrim of the overlay over the page, and a raised panel with a border, a shadow and the corners of a panel. It grows in when it opens and fades out when it closes. While it is open, focus stays inside it, the page does not scroll and the rest of it is out of reach; Escape, a press on the scrim, the x and the other close triggers close it, and focus goes back to the trigger.

The parts

  • The overlay. Portal renders the backdrop and the positioner at the end of the page, above everything else. The positioner holds the content in the middle of the viewport, with a margin all round, and lets presses around it through to the backdrop.
  • The trigger has no look of its own: pass a Button with asChild.
  • The x. Dialog.CloseTrigger is a ghost x button, named "Close", in the top end corner of the content, 24 px square: the smallest target WCAG 2.2 asks for. The header and a title right in the content make room for it. Pass another label to say what closes, such as "Close the export".
  • Header, body, footer. The header stacks the title and the description. The body takes the rest of the height, and scrolls between the header and the footer when the dialog is taller than the viewport. The footer lays out its actions in a row, at the end. Every part is optional: a title and a field right in the content work too.
  • Other ways to close. With asChild, Dialog.CloseTrigger makes a button of yours close the dialog, keeping its look and place, as the Cancel button above.

Placement and size

placement on the root puts the dialog in the middle of the viewport (center, the default), or near its top (top), as a search that grows down as its results change. size sets how wide the dialog can grow: sm (400 px) for a question, md (560 px, the default) for a form or a search, lg (800 px) for more content. It never grows wider than the viewport.

<Dialog.Root placement="top" size="md">
  …
</Dialog.Root>

Open and close

The dialog keeps its own state; defaultOpen opens it from the start. To keep it yourself, pass open and onOpenChange, which reports each request to open or close as { open }, and the dialog changes when you pass the new open:

const [open, setOpen] = useState(false);

<Dialog.Root open={open} onOpenChange={({ open }) => setOpen(open)}>
  …
</Dialog.Root>;

closeOnEscape and closeOnInteractOutside turn off Escape and the press on the scrim. A dialog with modal={false} leaves the page within reach: render no backdrop for it. Focus goes to the first element of the dialog when it opens, or to initialFocusEl(), and back to the trigger, or to finalFocusEl(), when it closes. Every option of the Headless Dialog works the same here.

Alert dialogs

role="alertdialog" asks for an answer. Leave out the x, and put the safe answer first: the dialog focuses its first close trigger, and a press on the scrim does not close it.

<Dialog.Root role="alertdialog" size="sm">
  <Dialog.Trigger asChild>
    <Button variant="outline">Delete the take</Button>
  </Dialog.Trigger>
  <Portal>
    <Dialog.Backdrop />
    <Dialog.Positioner>
      <Dialog.Content>
        <Dialog.Header>
          <Dialog.Title>Delete the take?</Dialog.Title>
          <Dialog.Description>It is gone for good.</Dialog.Description>
        </Dialog.Header>
        <Dialog.Footer>
          <Dialog.CloseTrigger asChild>
            <Button variant="outline">Keep it</Button>
          </Dialog.CloseTrigger>
          <Dialog.CloseTrigger asChild>
            <Button onClick={deleteTake}>Delete</Button>
          </Dialog.CloseTrigger>
        </Dialog.Footer>
      </Dialog.Content>
    </Dialog.Positioner>
  </Portal>
</Dialog.Root>

Scrolling

The dialog is never taller than the viewport: a long Dialog.Body scrolls, from edge to edge of the dialog, while the header and the footer stay in view. It opens scrolled to the element focus moves to, such as initialFocusEl() below the fold. A body that holds only text takes no focus by itself, so give it tabIndex={0}: keyboard users can then focus it and scroll it with the arrow keys.

Anatomy

PartElementClassAttributes
Dialog.Rootnone——
Dialog.Triggerbuttoncontour-dialog__triggerdata-scope="dialog" data-part="trigger", aria-haspopup, aria-expanded, data-state
Dialog.Backdropdivcontour-dialog__backdropdata-part="backdrop", data-state
Dialog.Positionerdivcontour-dialog__positionerdata-part="positioner", data-state, data-placement
Dialog.Contentdivcontour-dialog__contentdata-part="content", role="dialog", aria-modal, data-state, data-placement, data-size
Dialog.CloseTriggerbuttoncontour-dialog__close-triggerdata-part="close-trigger"; with asChild, the class of your element
Dialog.Headerdivcontour-dialog__headerdata-part="header"
Dialog.Bodydivcontour-dialog__bodydata-part="body"
Dialog.Footerdivcontour-dialog__footerdata-part="footer"
Dialog.Titleh2contour-dialog__titledata-part="title", id
Dialog.Descriptionpcontour-dialog__descriptiondata-part="description", id

Every part carries data-scope="dialog"; data-state is "open" or "closed". The closed overlay stays mounted, hidden, unless unmountOnExit; while it fades out it is inert. The content, the positioner and the backdrop get --layer-index while the dialog is open: their place among the open overlays, so a dialog opened from another one goes above it. The Headless Dialog lists every attribute.

Props

Dialog.Root takes every prop of the Headless Dialog (open, defaultOpen, onOpenChange, modal, closeOnEscape, closeOnInteractOutside, role, initialFocusEl, finalFocusEl, restoreFocus, lazyMount, unmountOnExit…), and:

PropValuesDefault
placement'center', 'top''center'
size'sm', 'md', 'lg''md'

Dialog.CloseTrigger takes the props of CloseButton, label ('Close'), variant ('ghost') and size ('md') among them, or, with asChild, renders its child. Every other part takes asChild and the props of its element, such as className, style and ref; className is merged with the part's class.

Keyboard

KeyAction
Enter, SpaceOn the trigger: opens the dialog. On a close trigger: closes it
Tab, Shift+TabMove focus to the next or the previous element, round the dialog
EscapeCloses the dialog

Accessibility

  • The content has the dialog role (or alertdialog), aria-modal, and is named by its title and described by its description: screen readers announce both as focus moves in. A dialog without a title needs an aria-label.
  • The x is named "Close". Name it after what closes when the page has several things that do.
  • While the dialog is open, the rest of the page is inert: out of reach of the pointer, the keyboard and screen readers.
  • The shadow is decoration: the raised background and the border set the dialog apart without it.
  • Under prefers-reduced-motion, the dialog fades in and out without growing.

Tokens

TokenUsed for
--ct-color-overlay-scrimthe backdrop
--ct-color-bg-raised, --ct-color-border-defaultthe background and the border of the content
--ct-border-width-default, --ct-radius-panelthe width of the border and the corners
--ct-shadow-overlaythe shadow of the content
--ct-size-dialog-sm, --ct-size-dialog-md, --ct-size-dialog-lgthe greatest width of the content, by size
--ct-space-panel-insetthe padding of the content and the gap of its parts
--ct-space-section-gapthe margin around the content
--ct-space-control-gap, --ct-space-group-gapthe header, the body and the footer
--ct-text-heading, --ct-color-fg-defaultthe title
--ct-text-body, --ct-color-fg-mutedthe description; other content is fg-default
--ct-size-control-mdthe x, and the room the header makes for it
--ct-layer-overlaythe z-index of the overlay, plus --layer-index
--ct-motion-enter, --ct-motion-exitthe animations of the content and the backdrop