Skip to content
Caseroom

Components · Overlays

Dialog

A modal on the native dialog element: focus stays inside, Escape and the backdrop close it, focus returns to the trigger.

0.78% of the 2,062 sites in The UI Index have a dialog (16 sites).

Rename project

The new name shows on the dashboard and in invites.

Delete Northwind site?

The project, its deploys and its domain settings are removed. This can't be undone.

Both previews render the same markup; the HTML tab below is that markup without a framework.

Install

React
npx shadcn@latest add https://www.themes.dev/caseroom/r/dialog.json
Astro
npx shadcn@latest add https://www.themes.dev/caseroom/r/astro/dialog.json

Or copy the files below into your project. They need clsx, tailwind-merge, and the Caseroom theme. The HTML needs only the compiled CSS and the script.

dialog.client.ts
// Dialog behaviour, shared by the Astro, React and HTML versions. A trigger is a button with command="show-modal"
// and commandfor="<dialog id>" (the browser's own Invoker Commands; this fills them in where they are missing). On
// open, focus goes to [autofocus], else the first field, else the first footer button: never the close button. On
// close, focus returns to the trigger. A click on the backdrop closes the dialog.
declare global {
  interface Window {
    __caseroomDialog?: boolean;
  }
}

const SELECTOR = 'dialog[data-dialog]';
const openers = new WeakMap<HTMLDialogElement, HTMLElement>();

function focusInitial(d: HTMLDialogElement) {
  const target =
    d.querySelector<HTMLElement>('[autofocus]') ??
    d.querySelector<HTMLElement>('[data-dialog-body] :is(input:not([type=hidden]), select, textarea):not(:disabled)') ??
    d.querySelector<HTMLElement>('[data-dialog-footer] :is(button, a[href]):not(:disabled)');
  target?.focus();
}

export function openDialog(d: HTMLDialogElement, opener?: HTMLElement | null) {
  if (opener) openers.set(d, opener);
  if (!d.open) d.showModal();
  focusInitial(d);
}

if (typeof window !== 'undefined' && !window.__caseroomDialog) {
  window.__caseroomDialog = true;
  const native = 'commandForElement' in HTMLButtonElement.prototype;
  document.addEventListener(
    'click',
    (e) => {
      const t = (e.target as Element).closest<HTMLButtonElement>('button[commandfor]');
      if (t && !t.disabled) {
        const d = document.getElementById(t.getAttribute('commandfor') ?? '');
        if (d instanceof HTMLDialogElement && d.matches(SELECTOR)) {
          const command = t.getAttribute('command');
          if (command === 'show-modal') {
            // Native support opens it itself; either way we remember the opener and set the first focus.
            if (!native) e.preventDefault();
            queueMicrotask(() => openDialog(d, t));
          } else if (!native && (command === 'close' || command === 'request-close')) d.close();
        }
        return;
      }
      // A click on the backdrop lands on the dialog element itself, outside its box.
      const d = e.target;
      if (d instanceof HTMLDialogElement && d.matches(SELECTOR) && d.open) {
        const r = d.getBoundingClientRect();
        const ev = e as MouseEvent;
        if (ev.clientX < r.left || ev.clientX > r.right || ev.clientY < r.top || ev.clientY > r.bottom) d.close();
      }
    },
    true,
  );
  document.addEventListener(
    'close',
    (e) => {
      const d = e.target;
      if (!(d instanceof HTMLDialogElement) || !d.matches(SELECTOR)) return;
      const opener = openers.get(d);
      if (opener?.isConnected) opener.focus();
    },
    true,
  );
}

export {};
dialog.tsx
import { useEffect, useRef, type ReactNode } from 'react';
import { cn } from '@/lib/utils';
import { openDialog } from './dialog.client';
import { CLOSE_PATH, descriptionId, dialog, titleId } from './dialog.variants';

export interface DialogProps {
  /** Unique on the page. A trigger opens it with command="show-modal" commandfor={id}. */
  id: string;
  title: string;
  description?: string;
  /** Controlled mode: open and close it from state. */
  open?: boolean;
  onOpenChange?: (open: boolean) => void;
  /** Buttons in the footer. They submit the dialog's form and close it; give them value="…" to tell them apart. */
  footer?: ReactNode;
  className?: string;
  children?: ReactNode;
}

/** A modal on the native <dialog>: focus is trapped by the browser, Escape and the backdrop close it, and focus
 *  returns to the trigger. Open it with a trigger (command/commandfor) or with `open`. */
export function Dialog({ id, title, description, open, onOpenChange, footer, className, children }: DialogProps) {
  const ref = useRef<HTMLDialogElement>(null);
  useEffect(() => {
    const d = ref.current;
    if (!d || open === undefined) return;
    if (open && !d.open) openDialog(d, document.activeElement as HTMLElement | null);
    if (!open && d.open) d.close();
  }, [open]);
  useEffect(() => {
    const d = ref.current;
    if (!d || !onOpenChange) return;
    const close = () => onOpenChange(false);
    d.addEventListener('close', close);
    return () => d.removeEventListener('close', close);
  }, [onOpenChange]);

  return (
    <dialog ref={ref} id={id} data-dialog="" aria-labelledby={titleId(id)} aria-describedby={description ? descriptionId(id) : undefined} className={cn(dialog.root, className)}>
      <div className={dialog.header}>
        <div className="min-w-0">
          <h2 id={titleId(id)} className={dialog.title}>{title}</h2>
          {description && <p id={descriptionId(id)} className={dialog.description}>{description}</p>}
        </div>
        <form method="dialog" className={dialog.close}>
          <button type="submit" aria-label="Close" className="inline-flex size-8 cursor-pointer items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground active:bg-pressed">
            <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" aria-hidden="true" className="size-4"><path d={CLOSE_PATH} /></svg>
          </button>
        </form>
      </div>
      <div data-dialog-body="" className={dialog.body}>{children}</div>
      {footer && <form method="dialog" data-dialog-footer="" className={dialog.footer}>{footer}</form>}
    </dialog>
  );
}
dialog.variants.ts
/** A modal on the native <dialog>: the browser traps focus, Escape closes, the page behind is inert. It fades and
 *  rises in (opacity and a 4px move; with reduced motion it only appears). */
export const dialog = {
  root: 'm-auto w-[calc(100%-2rem)] max-w-md rounded-xl border bg-popover p-0 text-popover-foreground shadow-2xl dark:shadow-[0_24px_64px_rgb(0_0_0/0.7)] backdrop:bg-black/50 backdrop:backdrop-blur-[2px] opacity-0 translate-y-1 transition-[opacity,translate,display,overlay] transition-discrete duration-150 ease-out open:opacity-100 open:translate-y-0 starting:open:opacity-0 starting:open:translate-y-1',
  header: 'flex items-start justify-between gap-4 px-5 pt-5',
  title: 'text-base font-semibold tracking-[-0.01em]',
  description: 'mt-1 text-sm text-pretty text-muted-foreground',
  close: '-mt-1.5 -mr-2 shrink-0',
  body: 'px-5 py-4 text-sm',
  footer: 'flex flex-wrap items-center justify-end gap-2 border-t bg-muted/50 px-5 py-3',
};

export const titleId = (id: string) => `${id}-title`;
export const descriptionId = (id: string) => `${id}-description`;
export const CLOSE_PATH = 'M4 4l8 8M12 4l-8 8';