# Dialog

A modal panel for one decision or one short task: a title, what happens, and the buttons that do it, with pending and error handled.

Tier: free. Type: component. Category: overlays. Tags: keyboard, motion.

## Install

```bash
npx shadcn@latest add @pixilui/dialog
```

One-time setup: add the registry to `components.json`:

```json
{
  "registries": {
    "@pixilui": "https://pixilui.com/r/{name}.json"
  }
}
```

Files: `components/pixilui/dialog.tsx`. npm: `@base-ui/react`, `motion`.

## Preview

Live preview: https://pixilui.com/components/dialog

Knobs:

- `variant`: `default`, `danger`
- `size`: `md`, `sm`, `lg`
- `state`: `open`, `closed`

Signature: The panel blooms out of a 2px blur on the morph spring, and a danger dialog lands focus on Cancel, never on Delete.

## Code

`components/pixilui/dialog.tsx`

```tsx
"use client";

import {
  createContext,
  forwardRef,
  isValidElement,
  useCallback,
  useContext,
  useId,
  useMemo,
  useRef,
  useState,
  type ComponentPropsWithoutRef,
  type ReactElement,
  type ReactNode,
  type RefObject,
} from "react";
import { Dialog as BaseDialog } from "@base-ui/react/dialog";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { cn } from "./lib/utils";
import { motionTokens, reducedTransition } from "./motion-tokens";
import { Button, type ButtonProps } from "./button";

export type DialogSize = "sm" | "md" | "lg";
export type DialogVariant = "default" | "danger";

/** Where an overlay portals to: an element, or null while that element is not mounted yet. */
export type DialogContainer = HTMLElement | null;

export type DialogProps = Omit<ComponentPropsWithoutRef<"div">, "children" | "role"> & {
  /** The dialog's parts: DialogTitle, DialogDescription, your content, DialogActions. */
  children?: ReactNode;
  /** Controlled open state. Pair with onOpenChange. */
  open?: boolean;
  /** Starting open state when uncontrolled. */
  defaultOpen?: boolean;
  /** Runs whenever the dialog opens or closes, from the trigger, Esc, an outside press or an action. */
  onOpenChange?: (open: boolean) => void;
  /** The element that opens the dialog, usually a Button. It is rendered through Base UI's render prop, so any element works and keeps its own props. */
  trigger?: ReactElement;
  /** Panel width: sm is 384px, md 448px, lg 576px. It never exceeds the viewport. */
  size?: DialogSize;
  /** danger tints the icon tile, styles the confirm action with the danger tokens, places it second and focuses Cancel first. */
  variant?: DialogVariant;
  /** Close on Esc and on a press outside the panel. Turn off for a decision that must be made. */
  dismissible?: boolean;
  /** A mark shown in a tile above the title. Components never bake in an icon. */
  icon?: ReactNode;
  /** Where the dialog portals to. Defaults to document.body. Pass an element (from a callback ref kept in state) to keep it inside a region; null waits for it. */
  container?: DialogContainer;
  /** true traps focus, locks page scroll and blocks the page; "trap-focus" only traps focus. Defaults to true, or "trap-focus" when a container is set. */
  modal?: boolean | "trap-focus";
};

type DialogContextValue = {
  variant: DialogVariant;
  close: () => void;
  cancelRef: RefObject<HTMLButtonElement | null>;
  setPending: (pending: boolean) => void;
};

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

function useDialogContext(part: string) {
  const context = useContext(DialogContext);
  if (!context) throw new Error(`${part} must be used inside a Dialog or Drawer.`);
  return context;
}

/** Shares the dialog's close action and variant with parts. Drawer reuses it with its own panel. */
export const DialogProvider = DialogContext.Provider;

const sizes: Record<DialogSize, string> = {
  sm: "max-w-sm",
  md: "max-w-md",
  lg: "max-w-xl",
};

/** Base UI wants nativeButton={false} when the trigger renders something other than a button. */
export function dialogTriggerIsNativeButton(trigger: ReactElement) {
  return typeof trigger.type === "string" ? trigger.type === "button" : true;
}

export const Dialog = forwardRef<HTMLDivElement, DialogProps>(function Dialog(
  {
    children,
    open: openProp,
    defaultOpen = false,
    onOpenChange,
    trigger,
    size = "md",
    variant = "default",
    dismissible = true,
    icon,
    container,
    modal,
    className,
    ...rest
  },
  forwardedRef,
) {
  const reduced = useReducedMotion() ?? false;
  const [innerOpen, setInnerOpen] = useState(defaultOpen);
  const open = openProp ?? innerOpen;
  const generatedTriggerId = useId();
  const triggerId = (isValidElement<{ id?: string }>(trigger) && trigger.props.id) || generatedTriggerId;
  const cancelRef = useRef<HTMLButtonElement | null>(null);
  const pendingRef = useRef(false);

  const setOpen = useCallback(
    (next: boolean) => {
      setInnerOpen(next);
      onOpenChange?.(next);
    },
    [onOpenChange],
  );

  const context = useMemo<DialogContextValue>(
    () => ({
      variant,
      close: () => setOpen(false),
      cancelRef,
      setPending: (pending) => {
        pendingRef.current = pending;
      },
    }),
    [variant, setOpen],
  );

  const backdrop = reduced
    ? { initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0 }, transition: reducedTransition }
    : {
        initial: { opacity: 0 },
        animate: { opacity: 1 },
        exit: { opacity: 0, transition: { duration: motionTokens.duration.quick, ease: motionTokens.ease.exit } },
        transition: { duration: motionTokens.duration.fast, ease: motionTokens.ease.smoothOut },
      };

  const blur = `blur(${motionTokens.blur.subtle}px)`;
  const panel = reduced
    ? { initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0 }, transition: reducedTransition }
    : {
        initial: { opacity: 0, scale: motionTokens.scale.enterFrom, filter: blur },
        animate: { opacity: 1, scale: 1, filter: "blur(0px)" },
        exit: {
          opacity: 0,
          scale: motionTokens.scale.enterFrom,
          filter: blur,
          transition: { duration: motionTokens.duration.quick, ease: motionTokens.ease.exit },
        },
        transition: {
          scale: motionTokens.spring.morph,
          default: { duration: motionTokens.duration.fast, ease: motionTokens.ease.enter },
        },
      };

  const state = open ? "open" : "closed";

  return (
    <BaseDialog.Root
      open={open}
      triggerId={trigger ? triggerId : undefined}
      modal={modal ?? (container !== undefined ? "trap-focus" : true)}
      disablePointerDismissal={!dismissible}
      onOpenChange={(next, details) => {
        // Esc and outside presses respect `dismissible`; nothing closes while an action is pending.
        if (!next && (pendingRef.current || (!dismissible && details.reason === "escape-key"))) {
          details.cancel();
          return;
        }
        setOpen(next);
      }}
    >
      {trigger ? <BaseDialog.Trigger id={triggerId} nativeButton={dialogTriggerIsNativeButton(trigger)} render={trigger} /> : null}
      <AnimatePresence>
        {open ? (
          <BaseDialog.Portal keepMounted container={container}>
            <BaseDialog.Backdrop
              data-slot="dialog-backdrop"
              data-state={state}
              className="fixed inset-0 bg-background/70 backdrop-blur-subtle"
              render={<motion.div {...backdrop} />}
            />
            <BaseDialog.Viewport className="fixed inset-0 flex items-center justify-center p-4 sm:p-6">
              <DialogProvider value={context}>
                <BaseDialog.Popup
                  ref={forwardedRef}
                  role={variant === "danger" ? "alertdialog" : "dialog"}
                  data-slot="dialog"
                  data-state={state}
                  data-size={size}
                  data-variant={variant}
                  initialFocus={variant === "danger" ? () => cancelRef.current ?? true : undefined}
                  className={cn(
                    "flex max-h-full w-full flex-col overflow-hidden rounded-panel bg-surface-raised font-body text-14 text-foreground shadow-floating outline-none hairline",
                    sizes[size],
                    className,
                  )}
                  render={<motion.div {...panel} />}
                  {...rest}
                >
                  <div data-slot="dialog-body" className="flex min-h-0 flex-col gap-2 overflow-y-auto overscroll-contain p-6">
                    {icon ? (
                      <span
                        data-slot="dialog-icon"
                        className={cn(
                          "mb-2 grid size-10 shrink-0 place-items-center rounded-control [&_svg]:size-5",
                          variant === "danger" ? "bg-danger-subtle text-danger" : "bg-surface-muted text-foreground",
                        )}
                      >
                        {icon}
                      </span>
                    ) : null}
                    {children}
                  </div>
                </BaseDialog.Popup>
              </DialogProvider>
            </BaseDialog.Viewport>
          </BaseDialog.Portal>
        ) : null}
      </AnimatePresence>
    </BaseDialog.Root>
  );
});

export type DialogTitleProps = ComponentPropsWithoutRef<"h2">;

/** The dialog's name. Base UI wires it to aria-labelledby. */
export const DialogTitle = forwardRef<HTMLHeadingElement, DialogTitleProps>(function DialogTitle({ className, ...rest }, ref) {
  return <BaseDialog.Title ref={ref} className={cn("text-pretty text-18 font-medium text-foreground", className)} {...rest} />;
});

export type DialogDescriptionProps = ComponentPropsWithoutRef<"p">;

/** What happens if the person goes ahead. Base UI wires it to aria-describedby. */
export const DialogDescription = forwardRef<HTMLParagraphElement, DialogDescriptionProps>(function DialogDescription({ className, ...rest }, ref) {
  return <BaseDialog.Description ref={ref} className={cn("text-pretty text-14 text-secondary", className)} {...rest} />;
});

export type DialogCloseProps = ButtonProps;

/** A Button that closes the dialog. Secondary by default; pass variant, size or icon like any Button. */
export const DialogClose = forwardRef<HTMLButtonElement, DialogCloseProps>(function DialogClose(
  { variant = "secondary", size = "md", icon, iconEnd, loading, ...rest },
  ref,
) {
  return (
    <BaseDialog.Close
      ref={ref}
      render={(props) => <Button {...(props as ButtonProps)} variant={variant} size={size} icon={icon} iconEnd={iconEnd} loading={loading} />}
      {...(rest as ComponentPropsWithoutRef<"button">)}
    />
  );
});

export type DialogActionsLabels = {
  cancel: string;
  error: string;
};

export type DialogActionsProps = ComponentPropsWithoutRef<"div"> & {
  /** Label of the main action, as a verb: "Delete workspace". When set, the actions render Cancel, then this button. */
  confirm?: string;
  /** Runs on the main action. Return a promise and the button shows pending; the dialog closes when it resolves and stays open if it throws. */
  onConfirm?: () => void | Promise<void>;
  /** Leave the dialog open after onConfirm resolves. */
  keepOpen?: boolean;
  /** Copy overrides for Cancel and the error line. */
  labels?: Partial<DialogActionsLabels>;
};

const defaultActionLabels: DialogActionsLabels = {
  cancel: "Cancel",
  error: "That did not go through. Try again.",
};

/**
 * The button row. It sticks to the bottom while long content scrolls, and stacks full width
 * on narrow screens with the main action on top.
 */
export const DialogActions = forwardRef<HTMLDivElement, DialogActionsProps>(function DialogActions(
  { confirm, onConfirm, keepOpen = false, labels: labelsProp, className, children, ...rest },
  ref,
) {
  const { variant, close, cancelRef, setPending } = useDialogContext("DialogActions");
  const labels = { ...defaultActionLabels, ...labelsProp };
  const [pending, setPendingState] = useState(false);
  const [error, setError] = useState(false);

  const run = async () => {
    if (pending) return;
    setError(false);
    setPendingState(true);
    setPending(true);
    try {
      await onConfirm?.();
      setPending(false);
      setPendingState(false);
      if (!keepOpen) close();
    } catch {
      setPending(false);
      setPendingState(false);
      setError(true);
    }
  };

  return (
    <div
      ref={ref}
      data-slot="dialog-actions"
      data-pending={pending ? "" : undefined}
      className={cn("sticky -bottom-6 -mx-6 -mb-6 mt-4 flex flex-col gap-3 bg-surface-raised px-6 pb-6 pt-2", className)}
      {...rest}
    >
      <p className={cn("text-14 text-danger", !error && "sr-only")} aria-live="polite">
        {error ? labels.error : ""}
      </p>
      <div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end [&>*]:w-full sm:[&>*]:w-auto">
        {children}
        {confirm ? (
          <>
            <DialogClose ref={cancelRef} aria-disabled={pending || undefined}>
              {labels.cancel}
            </DialogClose>
            <Button variant={variant === "danger" ? "danger" : "primary"} loading={pending} onClick={() => void run()}>
              {confirm}
            </Button>
          </>
        ) : null}
      </div>
    </div>
  );
});
```

## Usage

When to use:

- A decision has to be made before the person goes on: connect a tool, delete an agent, discard a run.
- A short task that needs focus and fits on one screen: rename, invite, set a budget.

When not to use:

- Details the person reads while working on the page: use a drawer.
- A few options anchored to a control: use a popover.
- An agent asking a human to sign off inside a run: use approval-card, which stays in the timeline.

```tsx
import { useState } from "react";
import { Plug, Trash2 } from "lucide-react";
import { Button } from "@/components/pixilui/button";
import { Dialog, DialogActions, DialogDescription, DialogTitle } from "@/components/pixilui/dialog";

// The first option of each knob is the demo's default; the docs preview starts there.
export const knobs = {
  variant: ["default", "danger"],
  size: ["md", "sm", "lg"],
  state: ["open", "closed"],
} as const;

const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));

const scopes = [
  { label: "Reads", value: "Issues and comments in Support, Platform" },
  { label: "Writes", value: "Draft replies, labels" },
  { label: "Never", value: "Closes or deletes an issue" },
];

export default function DialogDemo({ variant = "default", size = "md", state = "open" }: KnobValues<typeof knobs>) {
  // The dialog portals into this stage, so it keeps the preview's theme and accent and stays inside it.
  // It waits while the stage is null, so pass the element itself (a ref object would resolve too late).
  const [stage, setStage] = useState<HTMLDivElement | null>(null);
  const danger = variant === "danger";

  return (
    <div ref={setStage} className="relative grid h-[460px] w-full max-w-2xl place-items-center overflow-hidden rounded-surface bg-surface hairline [contain:layout]">
      <Dialog
        key={`${variant}-${size}-${state}`}
        container={stage}
        defaultOpen={state === "open"}
        size={size}
        variant={variant}
        icon={danger ? <Trash2 strokeWidth={1.5} /> : <Plug strokeWidth={1.5} />}
        trigger={danger ? <Button variant="secondary">Delete agent</Button> : <Button>Connect Linear</Button>}
      >
        {danger ? (
          <>
            <DialogTitle>Delete the Billing agent?</DialogTitle>
            <DialogDescription>
              Its 214 runs, 3 schedules and the Stripe connection go with it. Reminders it already sent stay in your customers&apos; inboxes.
            </DialogDescription>
            <DialogActions confirm="Delete agent" onConfirm={() => wait(900)} />
          </>
        ) : (
          <>
            <DialogTitle>Connect Linear to the triage agent</DialogTitle>
            <DialogDescription>The agent drafts a reply for every new issue and waits for a person to send it.</DialogDescription>
            <dl className="mt-2 flex flex-col rounded-control bg-surface-muted px-3.5 py-1 text-14">
              {scopes.map((scope, index) => (
                <div key={scope.label} className={index > 0 ? "flex gap-4 border-t border-border-subtle py-2.5" : "flex gap-4 py-2.5"}>
                  <dt className="w-14 shrink-0 text-secondary">{scope.label}</dt>
                  <dd className="min-w-0 text-foreground">{scope.value}</dd>
                </div>
              ))}
            </dl>
            <DialogActions confirm="Connect Linear" onConfirm={() => wait(900)} />
          </>
        )}
      </Dialog>
    </div>
  );
}
```

## Examples

None.

## API

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `children` | `ReactNode` |  | The dialog's parts: DialogTitle, DialogDescription, your content, DialogActions. |
| `open` | `boolean` |  | Controlled open state. Pair with onOpenChange. |
| `defaultOpen` | `boolean` | `false` | Starting open state when uncontrolled. |
| `onOpenChange` | `(open: boolean) => void` |  | Runs whenever the dialog opens or closes, from the trigger, Esc, an outside press or an action. |
| `trigger` | `ReactElement` |  | The element that opens the dialog, usually a Button. It is rendered through Base UI's render prop, so any element works and keeps its own props. |
| `size` | `"sm" \| "md" \| "lg"` | `"md"` | Panel width: sm is 384px, md 448px, lg 576px. It never exceeds the viewport. |
| `variant` | `"default" \| "danger"` | `"default"` | danger tints the icon tile, styles the confirm action with the danger tokens, places it second and focuses Cancel first. |
| `dismissible` | `boolean` | `true` | Close on Esc and on a press outside the panel. Turn off for a decision that must be made. |
| `icon` | `ReactNode` |  | A mark shown in a tile above the title. Components never bake in an icon. |
| `container` | `DialogContainer` |  | Where the dialog portals to. Defaults to document.body. Pass an element (from a callback ref kept in state) to keep it inside a region; null waits for it. |
| `modal` | `boolean \| "trap-focus"` |  | true traps focus, locks page scroll and blocks the page; "trap-focus" only traps focus. Defaults to true, or "trap-focus" when a container is set. |

Also accepts every native `<div>` attribute except `role`, and a ref to it.

## Keyboard

- `Enter`: On the trigger, opens the dialog and moves focus inside it.
- `Tab`: Moves between the controls in the dialog; focus never leaves it while it is open.
- `Esc`: Closes the dialog and returns focus to the trigger, unless dismissible is false or an action is pending.

## Accessibility

- Base UI renders role dialog with aria-modal and wires DialogTitle and DialogDescription to aria-labelledby and aria-describedby.
- The danger variant renders role alertdialog and puts focus on Cancel first, so a stray Enter never deletes anything.
- Focus is trapped while open and returns to the trigger on close; page scroll is locked for a full-page dialog.
- The confirm action shows pending with aria-busy and aria-disabled, never disabled, so focus stays on it; errors are announced in a polite live region.

## Motion

- The panel opens on the morph spring from scale 0.96 while a 2px blur clears, and closes faster on the exit ease in 150ms.
- The backdrop fades in on duration-fast.
- Reduced motion: both fade only, with no scale or blur.

## Responsive

- The panel fills the width up to its size (384, 448 or 576px) with 16px of margin, and never exceeds the viewport height.
- Long content scrolls inside the panel while the actions stay pinned to the bottom.
- Below 640px the actions stack full width with the main action on top.

## Performance

- Nothing is mounted while closed; the panel and backdrop mount on open and unmount after the exit animation.

## Notes for AI

- Pass the opener as trigger (any element; a Button is usual). For a dialog opened from code, leave trigger out and control open and onOpenChange.
- Compose the parts: DialogTitle, DialogDescription, your content, then DialogActions with confirm and onConfirm.
- Return a promise from onConfirm: the button shows pending, Esc and outside presses wait, and the dialog closes when it resolves or shows an error when it throws.
- Name the action in confirm ("Delete agent", "Connect Linear"), never "OK" or "Continue".
- Use variant danger for anything destructive; it styles the action with the danger tokens, puts it second and focuses Cancel.
- Pass container to keep the dialog inside a region of the page; it then traps focus without locking page scroll.

## Related

- [Drawer](https://pixilui.com/components/drawer/markdown): A sheet that slides in from the right, left or bottom for details read alongside the page: a run, a trace, a record, a settings panel.
- [Popover](https://pixilui.com/components/popover/markdown): A small panel anchored to a control for a closer look or a quick change: a run's budget, a model's settings, who can see a thread.
- [Approval card](https://pixilui.com/components/approval-card/markdown): An agent asks before it acts: what it will do, the rows that matter, the risk, and approve or reject with a note.
- [Button](https://pixilui.com/components/button/markdown): A button whose label can change without a jump: the width springs to the new words while they swap in place.
