# Dialog

Built on the native <dialog> element for focus trapping and Escape, with a blur-and-scale entrance and exit.

**Category:** Overlays  
**Exports:** `Dialog`  
**npm dependencies:** motion (+ clsx, tailwind-merge for `cn`)

## Install

```bash
npx shadcn@latest add https://charcoalui.com/r/dialog.json
```
The file lands at `components/ui/dialog.tsx`; import from `@/components/ui/dialog`.

## API — Dialog
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `open` | `boolean` | — |  |
| `onOpenChange` | `(open: boolean) => void` | — |  |
| `title` | `ReactNode` | — |  |
| `description` | `ReactNode` | — |  |
| `footer` | `ReactNode` | — |  |
| `children` | `ReactNode` | — |  |

## Example — Dialog

```tsx
import * as React from "react";
import { Dialog } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";

export default function DialogDemo({
  title = "Delete “Charcoal”?",
  description = "This removes the project, its 14 components and every deploy. There is no undo.",
}: {
  title?: string;
  description?: string;
}) {
  const [open, setOpen] = React.useState(false);
  return (
    <>
      <Button variant="destructive" onClick={() => setOpen(true)}>
        Delete project
      </Button>
      <Dialog
        open={open}
        onOpenChange={setOpen}
        title={title}
        description={description}
        footer={
          <>
            <Button variant="ghost" onClick={() => setOpen(false)}>
              Cancel
            </Button>
            <Button variant="destructive" onClick={() => setOpen(false)}>
              Delete project
            </Button>
          </>
        }
      />
    </>
  );
}
```

## Source — components/ui/dialog.tsx

```tsx
"use client";

import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";

export type DialogProps = {
  open: boolean;
  onOpenChange: (open: boolean) => void;
  title: React.ReactNode;
  description?: React.ReactNode;
  footer?: React.ReactNode;
  className?: string;
  children?: React.ReactNode;
};

/** Native <dialog> gives us the top layer, focus trapping, inert background and Escape. */
function useModal(open: boolean, onOpenChange: (open: boolean) => void) {
  const ref = React.useRef<HTMLDialogElement>(null);
  const [mounted, setMounted] = React.useState(open);
  if (open && !mounted) setMounted(true);
  React.useEffect(() => {
    const d = ref.current;
    if (open && d && !d.open) d.showModal();
  }, [open, mounted]);
  const onExitComplete = () => {
    ref.current?.close();
    setMounted(false);
  };
  const dialogProps = {
    ref,
    onCancel: (e: React.SyntheticEvent) => {
      e.preventDefault();
      onOpenChange(false);
    },
    className:
      "fixed inset-0 m-0 h-dvh max-h-none w-full max-w-none overflow-hidden bg-transparent p-0 text-foreground backdrop:bg-transparent",
  };
  return { mounted, dialogProps, onExitComplete };
}

export function Dialog({ open, onOpenChange, title, description, footer, className, children }: DialogProps) {
  const id = React.useId();
  const reduce = useReducedMotion();
  const { mounted, dialogProps, onExitComplete } = useModal(open, onOpenChange);
  if (!mounted) return null;

  return (
    <dialog {...dialogProps} aria-labelledby={`${id}-title`} aria-describedby={description ? `${id}-desc` : undefined}>
      <AnimatePresence onExitComplete={onExitComplete}>
        {open && (
          <motion.div key="layer" className="absolute inset-0 grid place-items-center p-4">
            <motion.div
              className="absolute inset-0 bg-background/60 backdrop-blur-sm"
              initial={{ opacity: 0 }}
              animate={{ opacity: 1 }}
              exit={{ opacity: 0 }}
              transition={{ duration: 0.2 }}
              onClick={() => onOpenChange(false)}
            />
            <motion.div
              className={cn(
                "relative w-full max-w-md rounded-2xl border bg-popover p-6 text-popover-foreground shadow-[0_24px_80px_-12px_rgb(0_0_0/0.5)]",
                className,
              )}
              initial={{ opacity: 0, scale: reduce ? 1 : 0.94, y: reduce ? 0 : 12, filter: "blur(8px)" }}
              animate={{ opacity: 1, scale: 1, y: 0, filter: "blur(0px)" }}
              exit={{ opacity: 0, scale: reduce ? 1 : 0.96, y: reduce ? 0 : 6, filter: "blur(4px)", transition: { duration: 0.16 } }}
              transition={{ type: "spring", stiffness: 420, damping: 32 }}
            >
              <button
                type="button"
                onClick={() => onOpenChange(false)}
                aria-label="Close"
                className="absolute right-3 top-3 grid size-8 cursor-pointer place-items-center rounded-lg text-muted-foreground outline-none transition-colors hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
              >
                <svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden>
                  <path d="M6 6l12 12M18 6 6 18" />
                </svg>
              </button>
              <h2 id={`${id}-title`} className="pr-8 text-lg font-semibold tracking-tight">
                {title}
              </h2>
              {description && (
                <p id={`${id}-desc`} className="mt-1.5 text-sm leading-6 text-muted-foreground">
                  {description}
                </p>
              )}
              {children && <div className="mt-5">{children}</div>}
              {footer && <div className="mt-6 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">{footer}</div>}
            </motion.div>
          </motion.div>
        )}
      </AnimatePresence>
    </dialog>
  );
}
```