# Dialog (Caseroom component)

A modal on the native dialog element: focus stays inside, Escape and the backdrop close it, focus returns to the trigger. Black and white, dark mode, shadcn-compatible; React, Astro and plain HTML.

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

## 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`
- HTML: https://www.themes.dev/caseroom/r/html/dialog.html (with https://www.themes.dev/caseroom/r/html/caseroom.css)

Page: https://www.themes.dev/caseroom/components/dialog/ · Rules for agents: https://www.themes.dev/caseroom/AGENT.md

## Code

### dialog.astro

```astro
---
import { cn } from '@/lib/utils';
import { CLOSE_PATH, descriptionId, dialog, titleId } from './dialog.variants';

interface Props {
  /** Unique on the page. A trigger opens it with command="show-modal" commandfor={id}. */
  id: string;
  title: string;
  description?: string;
  class?: string;
}

const { id, title, description, class: className } = Astro.props;
const hasFooter = Astro.slots.has('footer');
---

<dialog id={id} data-dialog aria-labelledby={titleId(id)} aria-describedby={description ? descriptionId(id) : undefined} class={cn(dialog.root, className)}>
  <div class={dialog.header}>
    <div class="min-w-0">
      <h2 id={titleId(id)} class={dialog.title}>{title}</h2>
      {description && <p id={descriptionId(id)} class={dialog.description}>{description}</p>}
    </div>
    <form method="dialog" class={dialog.close}>
      <button type="submit" aria-label="Close" class="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" stroke-width="1.5" stroke-linecap="round" aria-hidden="true" class="size-4"><path d={CLOSE_PATH} /></svg>
      </button>
    </form>
  </div>
  <div data-dialog-body class={dialog.body}><slot /></div>
  {hasFooter && <form method="dialog" data-dialog-footer class={dialog.footer}><slot name="footer" /></form>}
</dialog>

<script>
  import './dialog.client';
</script>
```

### dialog.client.ts

```tsx
// 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

```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

```tsx
/** 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';
```
