Dialog

A modal that interrupts. Every dialog costs the user their place, so open one only for a decision that genuinely cannot wait.

Feedback Dialog.jsx Dialog.d.ts Dialog.md

Playground

Change a prop and watch it. The code below follows along.

Install and import

Dialog is in @dovetail-ds/react. Install it with React, if you haven't:

npm install @dovetail-ds/react react react-dom
import { Dialog } from "@dovetail-ds/react";

Import @dovetail-ds/react/styles.css once at your app's root, then your theme after it. Setup covers the stylesheets, fonts and dark mode.

Guidelines

A modal that interrupts. Every dialog costs the user their place, so open one only for a decision that genuinely cannot wait.

Use it when

  • Confirming something destructive.
  • A short focused task that would lose context on its own page.

Don't use it when

  • It is a notification. Use Toast or Alert.
  • The form has more than about five fields. Give it a page.
  • Another dialog is already open. Stacked modals mean the flow is wrong.

Example

<Dialog
  open={open}
  onClose={close}
  title="Delete this project?"
  description="This removes all 42 documents inside it. You can't undo this."
  footer={<>
    <Button variant="secondary" onClick={close}>Cancel</Button>
    <Button variant="danger" onClick={confirm}>Delete project</Button>
  </>}
/>

Surfaces

surface="glass" or "glass-strong" lets the screen behind show through, blurred. It re-points --dt-dialog-bg and --dt-dialog-border-color to the glass roles, so every part of the dialog follows. The scrim still dims the page; glass reads best over a picture or a colourful screen.

<Dialog open={open} onClose={close} title="Share" surface="glass">…</Dialog>

Accessibility

role="dialog" with aria-modal, named by title (aria-labelledby) and described by description. A dialog with no visible title needs label. When it opens, focus moves into the panel; Tab and Shift+Tab stay inside it; the page behind stops scrolling. Escape, the scrim and the close button close it, and focus returns to whatever opened it.

Tokens

--dt-dialog-*, shared with Drawer and Popover so every overlay reads as one system.

Content

Title is a question for a decision, a noun phrase for a task. The confirming button restates the verb: "Delete project", never "OK" or "Yes".

Props

The typed contract, from Dialog.d.ts.

import * as React from "react";

/** Modal dialog. Interrupts the user, so reserve it for decisions that must be made now. */
export interface DialogProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "title"> {
  open: boolean;
  /** Called by the close button, the scrim, and Escape. */
  onClose?: () => void;
  /** Heading, and the dialog's accessible name. */
  title?: React.ReactNode;
  /** Supporting line below the title, and the dialog's accessible description. */
  description?: React.ReactNode;
  /** Right-aligned action row. Put the confirming action last. */
  footer?: React.ReactNode;
  /** @default "md" */
  size?: "sm" | "md" | "lg";
  /** "glass" lets what's behind show through, blurred (--dt-surface-glass with --dt-backdrop-glass); "glass-strong" lets less through. Use over a picture or a busy screen. @default "raised" */
  surface?: "raised" | "glass" | "glass-strong";
  /** Accessible name for a dialog with no visible title. */
  label?: string;
  children?: React.ReactNode;
}

export declare function Dialog(props: DialogProps): React.JSX.Element | null;

Tokens it reads

Every custom property Dialog.jsx resolves, read from the source: 13 component, 21 semantic. Component tier first, because that is the one to reach for.

  • component Its own tier. Override one of these and only this component moves.
  • semantic Shared roles. Override one and everything using that role moves with it.
TokenTierDeclared as
--dt-dialog-bgcomponentvar(--dt-surface-overlay)
--dt-dialog-border-colorcomponentvar(--dt-border-subtle)
--dt-dialog-border-widthcomponentvar(--dt-border-width-default)
--dt-dialog-elevationcomponentvar(--dt-elevation-4)
--dt-dialog-fgcomponentvar(--dt-text-primary)
--dt-dialog-gapcomponentvar(--dt-space-stack-md)
--dt-dialog-max-heightcomponent85vh
--dt-dialog-paddingcomponentvar(--dt-space-inset-xl)
--dt-dialog-radiuscomponentvar(--dt-radius-overlay)
--dt-dialog-scrimcomponentvar(--dt-surface-scrim)
--dt-dialog-width-lgcomponent720px
--dt-dialog-width-mdcomponent520px
--dt-dialog-width-smcomponent400px
--dt-backdrop-glasssemanticsaturate(1.6) blur(var(--dt-blur-glass))
--dt-border-glasssemanticcolor-mix(in oklab, var(--dt-text-primary) 10%, transparent)
--dt-radius-controlsemanticvar(--dt-radius-raw-8)
--dt-size-control-smsemanticvar(--dt-dim-8)
--dt-space-inline-xssemanticvar(--dt-dim-2)
--dt-space-inset-lgsemanticvar(--dt-dim-6)
--dt-space-inset-mdsemanticvar(--dt-dim-4)
--dt-space-inset-xlsemanticvar(--dt-dim-8)
--dt-space-stack-xssemanticvar(--dt-dim-2)
--dt-surface-glasssemanticcolor-mix(in oklab, var(--dt-surface-overlay) 72%, transparent)
--dt-surface-glass-strongsemanticcolor-mix(in oklab, var(--dt-surface-overlay) 88%, transparent)
--dt-text-body-sm-familysemanticvar(--dt-font-family-sans)
--dt-text-body-sm-linesemanticvar(--dt-line-height-sm)
--dt-text-body-sm-sizesemanticvar(--dt-font-size-sm)
--dt-text-heading-md-familysemanticvar(--dt-font-family-sans)
--dt-text-heading-md-linesemanticvar(--dt-line-height-2xl)
--dt-text-heading-md-sizesemanticvar(--dt-font-size-2xl)
--dt-text-heading-md-trackingsemanticvar(--dt-tracking-tight)
--dt-text-heading-md-weightsemanticvar(--dt-font-weight-medium)
--dt-text-secondarysemanticvar(--dt-color-neutral-600)
--dt-z-dialogsemantic400

Source

React only, no dependencies, every value a token.

import React from "react";

const TABBABLE = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';

/* What a modal surface does while it is open: focus moves into the panel,
   Tab and Shift+Tab cycle inside it, Escape closes it, the page behind stops
   scrolling, and focus goes back where it came from on close. Drawer shares
   it; Sheet keeps its own copy, tied to its entry and exit animation. The
   panel needs tabIndex={-1} so it can take focus itself when it has no
   tabbable child. onClose is read through a ref, so a new inline callback on
   every render does not re-run the effect and bounce focus. */
export function useModalFocus(open, panel, onClose) {
  const close = React.useRef(onClose);
  React.useEffect(() => { close.current = onClose; });
  React.useEffect(() => {
    if (!open) return undefined;
    const el = panel.current;
    const prev = document.activeElement;
    const overflow = document.body.style.overflow;
    document.body.style.overflow = "hidden";
    if (el) el.focus({ preventScroll: true });
    const onKey = (e) => {
      if (e.key === "Escape") { close.current && close.current(); return; }
      if (e.key !== "Tab" || !el) return;
      const items = Array.prototype.filter.call(el.querySelectorAll(TABBABLE), (n) => n.offsetParent !== null);
      if (!items.length) { e.preventDefault(); el.focus(); return; }
      const first = items[0];
      const last = items[items.length - 1];
      const inside = el.contains(document.activeElement);
      if (e.shiftKey && (!inside || document.activeElement === first)) { e.preventDefault(); last.focus(); }
      else if (!e.shiftKey && (!inside || document.activeElement === last)) { e.preventDefault(); first.focus(); }
    };
    document.addEventListener("keydown", onKey);
    return () => {
      document.removeEventListener("keydown", onKey);
      document.body.style.overflow = overflow;
      if (prev && prev.focus) prev.focus();
    };
  }, [open, panel]);
}

/* Glass: the dialog's surface let through, blurred, for a dialog over a
   picture or a busy screen. It re-points the dialog's own tokens, so every
   part of it follows. */
const GLASS = {
  glass: { "--dt-dialog-bg": "var(--dt-surface-glass)", "--dt-dialog-border-color": "var(--dt-border-glass)", backdropFilter: "var(--dt-backdrop-glass)", WebkitBackdropFilter: "var(--dt-backdrop-glass)" },
  "glass-strong": { "--dt-dialog-bg": "var(--dt-surface-glass-strong)", "--dt-dialog-border-color": "var(--dt-border-glass)", backdropFilter: "var(--dt-backdrop-glass)", WebkitBackdropFilter: "var(--dt-backdrop-glass)" },
};

export function Dialog({ open, onClose, title, description, footer, size = "md", surface = "raised", label, style, children, ...rest }) {
  const panel = React.useRef(null);
  const titleId = React.useId();
  const descriptionId = React.useId();
  useModalFocus(open, panel, onClose);

  if (!open) return null;

  return (
    <div
      onClick={onClose}
      style={{
        position: "fixed", inset: 0, zIndex: "var(--dt-z-dialog)",
        background: "var(--dt-dialog-scrim)",
        display: "flex", alignItems: "center", justifyContent: "center",
        padding: "var(--dt-space-inset-lg)",
      }}
    >
      <div
        {...rest}
        ref={panel}
        role="dialog" aria-modal="true" tabIndex={-1}
        aria-labelledby={title ? titleId : undefined}
        aria-label={!title ? label : undefined}
        aria-describedby={description ? descriptionId : undefined}
        onClick={(e) => { e.stopPropagation(); rest.onClick && rest.onClick(e); }}
        style={{
          position: "relative",
          display: "flex", flexDirection: "column", gap: "var(--dt-dialog-gap)",
          width: `var(--dt-dialog-width-${size})`, maxWidth: "100%",
          maxHeight: "var(--dt-dialog-max-height)", overflowY: "auto",
          background: "var(--dt-dialog-bg)", color: "var(--dt-dialog-fg)",
          border: "var(--dt-dialog-border-width) solid var(--dt-dialog-border-color)",
          borderRadius: "var(--dt-dialog-radius)",
          padding: "var(--dt-dialog-padding)",
          boxShadow: "var(--dt-dialog-elevation)",
          ...(GLASS[surface] || null),
          ...style,
        }}
      >
        <button
          type="button" aria-label="Close" onClick={onClose}
          style={{
            position: "absolute", top: "var(--dt-space-inset-md)", right: "var(--dt-space-inset-md)",
            display: "flex", alignItems: "center", justifyContent: "center",
            width: "var(--dt-size-control-sm)", height: "var(--dt-size-control-sm)",
            padding: 0, border: 0, borderRadius: "var(--dt-radius-control)",
            background: "none", color: "var(--dt-text-secondary)", cursor: "pointer",
          }}
        >
          <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><path d="M18 6 6 18" /><path d="m6 6 12 12" /></svg>
        </button>
        {title && (
          <h2 id={titleId} style={{
            margin: 0, paddingRight: "var(--dt-space-inset-xl)",
            fontFamily: "var(--dt-text-heading-md-family)", fontSize: "var(--dt-text-heading-md-size)",
            lineHeight: "var(--dt-text-heading-md-line)", fontWeight: "var(--dt-text-heading-md-weight)",
            letterSpacing: "var(--dt-text-heading-md-tracking)",
          }}>{title}</h2>
        )}
        {description && (
          <p id={descriptionId} style={{ margin: 0, fontFamily: "var(--dt-text-body-sm-family)", fontSize: "var(--dt-text-body-sm-size)", lineHeight: "var(--dt-text-body-sm-line)", color: "var(--dt-text-secondary)" }}>{description}</p>
        )}
        {children}
        {footer && <div style={{ display: "flex", gap: "var(--dt-space-inline-xs)", justifyContent: "flex-end", marginTop: "var(--dt-space-stack-xs)" }}>{footer}</div>}
      </div>
    </div>
  );
}

In context

Dialog also appears on the feedback reference cards, alongside the rest of its family.