# Dialog

> Accessible modal built on the native dialog element.

Category: Overlay. Docs: https://get-neonkit.vercel.app/components/dialog

## Installation

```bash
npx shadcn@latest add https://get-neonkit.vercel.app/r/dialog.json
```

With the `@neonkit` namespace configured: `npx shadcn@latest add @neonkit/dialog`.

npm dependencies: `motion`, `lucide-react`. Also needs `lib/utils.ts` (`cn`) and `lib/neon.ts` (`neonVars`), which the CLI adds.

## Usage

```tsx
import {
  Dialog,
  DialogClose,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
  DialogTrigger,
} from "@/components/ui/dialog"

<Dialog>
  <DialogTrigger asChild>
    <Button>Edit profile</Button>
  </DialogTrigger>
  <DialogContent>
    <DialogHeader>
      <DialogTitle>Edit profile</DialogTitle>
      <DialogDescription>This is how teammates see you.</DialogDescription>
    </DialogHeader>
    <DialogFooter>
      <DialogClose asChild>
        <Button variant="ghost">Cancel</Button>
      </DialogClose>
    </DialogFooter>
  </DialogContent>
</Dialog>
```

## Examples

### Form

Control open to close the dialog once the form submits.

```tsx
"use client";

import * as React from "react";
import { Button } from "@/components/ui/button";
import {
  Dialog,
  DialogClose,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
  DialogTrigger,
} from "@/components/ui/dialog";

const field =
  "h-10 w-full rounded-lg border border-white/10 bg-white/[0.03] px-3 text-sm text-white/90 outline-none transition-[border-color,box-shadow] duration-150 placeholder:text-white/30 focus:border-[rgb(var(--neon)/0.6)] focus:shadow-[0_0_0_3px_rgb(var(--neon)/0.15)]";

export default function DialogDemo() {
  const [open, setOpen] = React.useState(false);

  return (
    <Dialog open={open} onOpenChange={setOpen}>
      <DialogTrigger asChild>
        <Button>Edit profile</Button>
      </DialogTrigger>
      <DialogContent>
        <form
          onSubmit={(event) => {
            event.preventDefault();
            setOpen(false);
          }}
        >
          <DialogHeader>
            <DialogTitle>Edit profile</DialogTitle>
            <DialogDescription>This is how teammates see you across Acme.</DialogDescription>
          </DialogHeader>

          <div className="mt-6 grid gap-4">
            <label className="grid gap-1.5">
              <span className="text-xs font-medium text-white/55">Name</span>
              <input name="name" defaultValue="Ana Lima" autoComplete="name" className={field} />
            </label>
            <label className="grid gap-1.5">
              <span className="text-xs font-medium text-white/55">Username</span>
              <div className="relative">
                <span className="pointer-events-none absolute inset-y-0 left-3 flex items-center text-sm text-white/30">
                  acme.dev/
                </span>
                <input
                  name="username"
                  defaultValue="ana"
                  autoComplete="username"
                  spellCheck={false}
                  className={`${field} pl-[78px]`}
                />
              </div>
            </label>
            <label className="grid gap-1.5">
              <span className="text-xs font-medium text-white/55">Role</span>
              <input name="role" defaultValue="Design engineer" className={field} />
            </label>
          </div>

          <DialogFooter>
            <DialogClose asChild>
              <Button variant="ghost">Cancel</Button>
            </DialogClose>
            <Button type="submit" variant="solid">
              Save changes
            </Button>
          </DialogFooter>
        </form>
      </DialogContent>
    </Dialog>
  );
}
```

### Destructive

color tints the panel, its glow and every button inside. Typing the name unlocks the action.

```tsx
"use client";

import * as React from "react";
import { Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
  Dialog,
  DialogClose,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
  DialogTrigger,
} from "@/components/ui/dialog";

const PROJECT = "acme-web";

export default function DialogDestructive() {
  const [open, setOpen] = React.useState(false);
  const [confirmation, setConfirmation] = React.useState("");
  const [deleting, setDeleting] = React.useState(false);

  function handleOpenChange(next: boolean) {
    if (next) setConfirmation("");
    setOpen(next);
  }

  function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
    event.preventDefault();
    setDeleting(true);
    setTimeout(() => {
      setDeleting(false);
      setOpen(false);
    }, 1200);
  }

  return (
    <Dialog color="red" open={open} onOpenChange={handleOpenChange}>
      <DialogTrigger asChild>
        <Button color="red" variant="soft">
          <Trash2 /> Delete project
        </Button>
      </DialogTrigger>
      <DialogContent size="sm">
        <form onSubmit={handleSubmit}>
          <DialogHeader>
            <DialogTitle>Delete project</DialogTitle>
            <DialogDescription>
              This permanently deletes <span className="text-white/80">{PROJECT}</span>, its 14
              deployments and every environment variable. This can&apos;t be undone.
            </DialogDescription>
          </DialogHeader>

          <label className="mt-5 grid gap-2">
            <span className="text-xs text-white/55">
              Type <span className="font-mono text-white/80">{PROJECT}</span> to confirm
            </span>
            <input
              value={confirmation}
              onChange={(event) => setConfirmation(event.target.value)}
              autoComplete="off"
              spellCheck={false}
              className="h-10 w-full rounded-lg border border-white/10 bg-white/[0.03] px-3 font-mono text-sm text-white/90 outline-none transition-[border-color,box-shadow] duration-150 focus:border-[rgb(var(--neon)/0.6)] focus:shadow-[0_0_0_3px_rgb(var(--neon)/0.15)]"
            />
          </label>

          <DialogFooter>
            <DialogClose asChild>
              <Button variant="ghost" color="white">
                Cancel
              </Button>
            </DialogClose>
            <Button
              type="submit"
              variant="solid"
              disabled={confirmation !== PROJECT}
              loading={deleting}
            >
              {deleting ? "Deleting" : "Delete project"}
            </Button>
          </DialogFooter>
        </form>
      </DialogContent>
    </Dialog>
  );
}
```

### Sizes

```tsx
"use client";

import * as React from "react";
import { Button } from "@/components/ui/button";
import {
  Dialog,
  DialogClose,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
  DialogTrigger,
} from "@/components/ui/dialog";

const SHARE_URL = "https://acme.dev/docs/q4-launch-plan";

const changes = [
  { title: "Preview comments", body: "Leave feedback on preview deployments, pinned to the element." },
  { title: "Faster cold starts", body: "Functions now boot 40% faster in every region." },
  { title: "Audit log export", body: "Download every workspace event from the last 90 days as CSV." },
  { title: "Custom roles", body: "Decide exactly what Viewers, Editors and Billing admins can change." },
];

function CopyLink() {
  const [copied, setCopied] = React.useState(false);

  async function copy() {
    try {
      await navigator.clipboard.writeText(SHARE_URL);
    } catch {
      return;
    }
    setCopied(true);
    setTimeout(() => setCopied(false), 1600);
  }

  return (
    <Button size="sm" variant="soft" onClick={copy} className="w-[84px]">
      {copied ? "Copied" : "Copy link"}
    </Button>
  );
}

export default function DialogSizes() {
  return (
    <div className="flex flex-wrap items-center justify-center gap-3">
      <Dialog>
        <DialogTrigger asChild>
          <Button size="sm">Small</Button>
        </DialogTrigger>
        <DialogContent size="sm">
          <DialogHeader>
            <DialogTitle>Sign out everywhere?</DialogTitle>
            <DialogDescription>
              You&apos;ll be signed out on 3 other devices, including the MacBook in Lisbon.
            </DialogDescription>
          </DialogHeader>
          <DialogFooter>
            <DialogClose asChild>
              <Button variant="ghost">Cancel</Button>
            </DialogClose>
            <DialogClose asChild>
              <Button variant="solid">Sign out</Button>
            </DialogClose>
          </DialogFooter>
        </DialogContent>
      </Dialog>

      <Dialog>
        <DialogTrigger asChild>
          <Button size="sm">Medium</Button>
        </DialogTrigger>
        <DialogContent>
          <DialogHeader>
            <DialogTitle>Share “Q4 launch plan”</DialogTitle>
            <DialogDescription>Anyone at Acme with the link can comment.</DialogDescription>
          </DialogHeader>
          <div className="mt-5 flex items-center gap-2 rounded-lg border border-white/10 bg-white/[0.03] py-1 pl-3 pr-1">
            <span className="min-w-0 flex-1 truncate font-mono text-xs text-white/70">{SHARE_URL}</span>
            <CopyLink />
          </div>
          <DialogFooter>
            <DialogClose asChild>
              <Button variant="ghost">Done</Button>
            </DialogClose>
          </DialogFooter>
        </DialogContent>
      </Dialog>

      <Dialog>
        <DialogTrigger asChild>
          <Button size="sm">Large</Button>
        </DialogTrigger>
        <DialogContent size="lg">
          <DialogHeader>
            <DialogTitle>What&apos;s new in 2.4</DialogTitle>
            <DialogDescription>Released October 9 · 4 changes</DialogDescription>
          </DialogHeader>
          <ul className="mt-6 grid gap-3 sm:grid-cols-2">
            {changes.map((change) => (
              <li key={change.title} className="rounded-xl border border-white/10 bg-white/[0.03] p-4">
                <p className="text-sm font-medium text-white/90">{change.title}</p>
                <p className="mt-1 text-sm leading-6 text-white/55">{change.body}</p>
              </li>
            ))}
          </ul>
          <DialogFooter>
            <DialogClose asChild>
              <Button variant="solid">Got it</Button>
            </DialogClose>
          </DialogFooter>
        </DialogContent>
      </Dialog>
    </div>
  );
}
```

## API reference

### Dialog

Holds the open state. Renders no element of its own.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `open` | `boolean` | — | Controlled open state. |
| `defaultOpen` | `boolean` | `false` | Initial state when uncontrolled. |
| `onOpenChange` | `(open: boolean) => void` | — | Called when the trigger, a close button, Escape or the overlay asks to change state. |
| `color` | `NeonColor` | `inherited` | Palette name or hex. Falls back to --neon-accent, then cyan. |

### DialogTrigger

Renders a button that opens the dialog. Also accepts every native button attribute.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `asChild` | `boolean` | `false` | Merges trigger behaviour onto its single child element instead of rendering a button. |

### DialogContent

The overlay and panel, rendered in a native <dialog> on the top layer. Other props go to the panel.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `size` | `"sm" \| "md" \| "lg"` | `"md"` | Maximum panel width: 384, 512 or 672px. |
| `showClose` | `boolean` | `true` | Shows the X button in the top-right corner. |

### DialogClose

Renders a button that closes the dialog. Also accepts every native button attribute.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `asChild` | `boolean` | `false` | Merges close behaviour onto its single child element instead of rendering a button. |

### DialogHeader, DialogTitle, DialogDescription, DialogFooter

Layout and text parts. Each accepts the native attributes of its element.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `DialogTitle` | `h2` | — | Labels the dialog through aria-labelledby. |
| `DialogDescription` | `p` | — | Describes the dialog through aria-describedby, only while it's rendered. |
| `DialogFooter` | `div` | — | Stacks actions on small screens and right-aligns them from sm up. |

## Accessibility

- Opens with showModal(), so the browser traps focus, makes the page inert and moves focus to the first focusable element.
- Escape and clicks on the overlay close the dialog after its exit animation.
- Focus returns to the element that opened the dialog, and page scroll is locked while it's open.
- Always include a DialogTitle, even a visually hidden one, so the dialog has an accessible name.

## Source

`components/ui/dialog.tsx`

```tsx
"use client";

import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { X } from "lucide-react";

import { cn } from "@/lib/utils";
import { neonVars, type NeonColor } from "@/lib/neon";

const EASE = [0.23, 1, 0.32, 1] as const;

interface DialogContextValue {
  open: boolean;
  setOpen: (open: boolean) => void;
  color?: NeonColor;
  contentId: string;
  titleId: string;
  descriptionId: string;
  hasDescription: boolean;
  setHasDescription: (value: boolean) => void;
  triggerRef: { current: HTMLElement | null };
}

const DialogContext = React.createContext<DialogContextValue | null>(null);

function useDialog(component: string) {
  const context = React.useContext(DialogContext);
  if (!context) throw new Error(`<${component}> must be rendered inside <Dialog>.`);
  return context;
}

/** Merges props onto its single child element, chaining the child's own onClick. */
const Slot = React.forwardRef<HTMLElement, React.HTMLAttributes<HTMLElement>>(
  ({ children, className, onClick, ...props }, ref) => {
    if (!React.isValidElement<React.HTMLAttributes<HTMLElement>>(children)) return null;
    const child = children;

    return React.cloneElement(child, {
      ...props,
      className: cn(child.props.className, className),
      onClick: (event: React.MouseEvent<HTMLElement>) => {
        child.props.onClick?.(event);
        if (!event.defaultPrevented) onClick?.(event);
      },
      ...(ref ? { ref } : null),
    } as React.HTMLAttributes<HTMLElement>);
  },
);
Slot.displayName = "DialogSlot";

export interface DialogProps {
  open?: boolean;
  defaultOpen?: boolean;
  onOpenChange?: (open: boolean) => void;
  /** Palette name or hex. Inherits `--neon-accent` when omitted. */
  color?: NeonColor;
  children?: React.ReactNode;
}

function Dialog({ open: openProp, defaultOpen = false, onOpenChange, color, children }: DialogProps) {
  const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen);
  const [hasDescription, setHasDescription] = React.useState(false);
  const triggerRef = React.useRef<HTMLElement | null>(null);
  const id = React.useId();
  const isControlled = openProp !== undefined;
  const open = isControlled ? openProp : uncontrolledOpen;

  const setOpen = React.useCallback(
    (next: boolean) => {
      if (next === open) return;
      if (!isControlled) setUncontrolledOpen(next);
      onOpenChange?.(next);
    },
    [open, isControlled, onOpenChange],
  );

  const value = React.useMemo<DialogContextValue>(
    () => ({
      open,
      setOpen,
      color,
      contentId: `${id}-content`,
      titleId: `${id}-title`,
      descriptionId: `${id}-description`,
      hasDescription,
      setHasDescription,
      triggerRef,
    }),
    [open, setOpen, color, id, hasDescription],
  );

  return <DialogContext.Provider value={value}>{children}</DialogContext.Provider>;
}
Dialog.displayName = "Dialog";

export interface DialogTriggerProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
  /** Merge trigger behaviour onto the single child element instead of rendering a button. */
  asChild?: boolean;
}

const DialogTrigger = React.forwardRef<HTMLButtonElement, DialogTriggerProps>(
  ({ asChild = false, onClick, children, ...props }, ref) => {
    const { open, setOpen, contentId, triggerRef } = useDialog("DialogTrigger");

    const handleClick = (event: React.MouseEvent<HTMLElement>) => {
      onClick?.(event as React.MouseEvent<HTMLButtonElement>);
      if (event.defaultPrevented) return;
      triggerRef.current = event.currentTarget;
      setOpen(true);
    };

    const triggerProps = {
      ...props,
      "aria-haspopup": "dialog" as const,
      "aria-expanded": open,
      "aria-controls": open ? contentId : undefined,
    };

    if (asChild) {
      return (
        <Slot ref={ref} {...triggerProps} onClick={handleClick}>
          {children}
        </Slot>
      );
    }

    return (
      <button ref={ref} type="button" {...triggerProps} onClick={handleClick}>
        {children}
      </button>
    );
  },
);
DialogTrigger.displayName = "DialogTrigger";

const sizes = {
  sm: "max-w-sm",
  md: "max-w-lg",
  lg: "max-w-2xl",
} as const;

export interface DialogContentProps
  extends Omit<
    React.HTMLAttributes<HTMLDivElement>,
    "color" | "onAnimationStart" | "onDrag" | "onDragStart" | "onDragEnd"
  > {
  size?: keyof typeof sizes;
  /** Renders the X button in the top-right corner. */
  showClose?: boolean;
}

const DialogContent = React.forwardRef<HTMLDivElement, DialogContentProps>(
  ({ size = "md", showClose = true, className, children, ...props }, ref) => {
    const { open, setOpen, color, contentId, titleId, descriptionId, hasDescription, triggerRef } =
      useDialog("DialogContent");
    const reduceMotion = useReducedMotion();
    const dialogRef = React.useRef<HTMLDialogElement>(null);
    const saved = React.useRef<{ overflow: string; focus: HTMLElement | null } | null>(null);

    React.useLayoutEffect(() => {
      const dialog = dialogRef.current;
      if (!open || !dialog || dialog.open) return;

      // Safari doesn't focus buttons on click, so fall back to the trigger that was pressed.
      const active = document.activeElement;
      saved.current = {
        overflow: document.documentElement.style.overflow,
        focus: active instanceof HTMLElement && active !== document.body ? active : triggerRef.current,
      };
      triggerRef.current = null;
      document.documentElement.style.overflow = "hidden";
      dialog.showModal();
    }, [open, triggerRef]);

    React.useEffect(
      () => () => {
        if (saved.current) document.documentElement.style.overflow = saved.current.overflow;
      },
      [],
    );

    // The <dialog> stays open while the panel animates out, then closes for real here.
    const finish = () => {
      const dialog = dialogRef.current;
      if (dialog?.open) dialog.close();
      const restore = saved.current;
      saved.current = null;
      if (!restore) return;
      document.documentElement.style.overflow = restore.overflow;
      if (restore.focus?.isConnected) restore.focus.focus({ preventScroll: true });
    };

    const enter = { duration: reduceMotion ? 0.15 : 0.22, ease: EASE };
    const exit = { duration: reduceMotion ? 0.12 : 0.16, ease: EASE };
    const hiddenPanel = reduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.96, y: 8 };

    return (
      <dialog
        ref={dialogRef}
        id={contentId}
        aria-labelledby={titleId}
        aria-describedby={hasDescription ? descriptionId : undefined}
        onCancel={(event) => {
          // Escape: let the exit animation run instead of closing instantly.
          event.preventDefault();
          setOpen(false);
        }}
        onClose={() => {
          if (!dialogRef.current?.open) setOpen(false);
        }}
        style={neonVars(color)}
        className={cn(
          "[--neon:var(--neon-accent,34_211_238)]",
          "fixed inset-0 m-0 h-dvh max-h-none w-screen max-w-none overflow-hidden border-0 bg-transparent p-0 text-white/90 outline-none backdrop:bg-transparent",
        )}
      >
        <AnimatePresence onExitComplete={finish}>
          {open && (
            <motion.div
              key="overlay"
              aria-hidden
              initial={{ opacity: 0 }}
              animate={{ opacity: 1 }}
              exit={{ opacity: 0, transition: exit }}
              transition={enter}
              onClick={() => setOpen(false)}
              className="absolute inset-0 bg-black/60 backdrop-blur-md"
            />
          )}
          {open && (
            <div
              key="viewport"
              className="pointer-events-none absolute inset-0 overflow-y-auto overscroll-contain"
            >
              <div className="flex min-h-full items-center justify-center p-4 sm:p-6">
                <motion.div
                  ref={ref}
                  initial={hiddenPanel}
                  animate={{ opacity: 1, scale: 1, y: 0 }}
                  exit={{ ...hiddenPanel, transition: exit }}
                  transition={enter}
                  className={cn(
                    "pointer-events-auto relative w-full rounded-2xl border border-[rgb(var(--neon)/0.18)] bg-[#0a0b11] p-6 text-white/90",
                    "shadow-[0_0_0_1px_rgb(0_0_0/0.5),0_24px_64px_-16px_rgb(0_0_0/0.9),0_0_80px_-24px_rgb(var(--neon)/0.35)]",
                    sizes[size],
                    className,
                  )}
                  {...props}
                >
                  <span
                    aria-hidden
                    className="pointer-events-none absolute inset-x-10 -top-px h-px bg-[linear-gradient(90deg,transparent,rgb(var(--neon)/0.8),transparent)]"
                  />
                  {children}
                  {showClose && (
                    <button
                      type="button"
                      aria-label="Close"
                      onClick={() => setOpen(false)}
                      className={cn(
                        "absolute right-3.5 top-3.5 flex size-8 items-center justify-center rounded-lg text-white/45 outline-none",
                        "transition-[background-color,color,transform] duration-150 ease-out hover:bg-white/[0.06] hover:text-white/90 active:scale-[0.97]",
                        "focus-visible:ring-2 focus-visible:ring-[rgb(var(--neon)/0.6)] focus-visible:ring-offset-2 focus-visible:ring-offset-[#0a0b11]",
                      )}
                    >
                      <X className="size-4" aria-hidden />
                    </button>
                  )}
                </motion.div>
              </div>
            </div>
          )}
        </AnimatePresence>
      </dialog>
    );
  },
);
DialogContent.displayName = "DialogContent";

const DialogHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
  ({ className, ...props }, ref) => (
    <div ref={ref} className={cn("flex flex-col gap-1.5 pr-8", className)} {...props} />
  ),
);
DialogHeader.displayName = "DialogHeader";

const DialogFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
  ({ className, ...props }, ref) => (
    <div
      ref={ref}
      className={cn("mt-6 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
      {...props}
    />
  ),
);
DialogFooter.displayName = "DialogFooter";

const DialogTitle = React.forwardRef<HTMLHeadingElement, React.HTMLAttributes<HTMLHeadingElement>>(
  ({ className, ...props }, ref) => {
    const { titleId } = useDialog("DialogTitle");
    return (
      <h2
        ref={ref}
        className={cn("text-base font-semibold leading-6 tracking-[-0.01em] text-white/90", className)}
        {...props}
        id={titleId}
      />
    );
  },
);
DialogTitle.displayName = "DialogTitle";

const DialogDescription = React.forwardRef<
  HTMLParagraphElement,
  React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => {
  const { descriptionId, setHasDescription } = useDialog("DialogDescription");

  React.useLayoutEffect(() => {
    setHasDescription(true);
    return () => setHasDescription(false);
  }, [setHasDescription]);

  return (
    <p
      ref={ref}
      className={cn("text-sm leading-6 text-white/55", className)}
      {...props}
      id={descriptionId}
    />
  );
});
DialogDescription.displayName = "DialogDescription";

export interface DialogCloseProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
  /** Merge close behaviour onto the single child element instead of rendering a button. */
  asChild?: boolean;
}

const DialogClose = React.forwardRef<HTMLButtonElement, DialogCloseProps>(
  ({ asChild = false, onClick, children, ...props }, ref) => {
    const { setOpen } = useDialog("DialogClose");

    const handleClick = (event: React.MouseEvent<HTMLElement>) => {
      onClick?.(event as React.MouseEvent<HTMLButtonElement>);
      if (!event.defaultPrevented) setOpen(false);
    };

    if (asChild) {
      return (
        <Slot ref={ref} {...props} onClick={handleClick}>
          {children}
        </Slot>
      );
    }

    return (
      <button ref={ref} type="button" {...props} onClick={handleClick}>
        {children}
      </button>
    );
  },
);
DialogClose.displayName = "DialogClose";

export {
  Dialog,
  DialogTrigger,
  DialogContent,
  DialogHeader,
  DialogFooter,
  DialogTitle,
  DialogDescription,
  DialogClose,
};
```
