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.

View as markdownFreeMITSince 0.1.0

Install#

npx shadcn@latest add @pixilui/dialog

Once per app, add the registry to components.json:

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

Or install from the URL, no namespace needed: npx shadcn@latest add https://pixilui.com/r/dialog.json

Adds components/pixilui/dialog.tsx. Needs @base-ui/react, motion, clsx, tailwind-merge.

Preview#

Variant
Size
State
Theme
Accent

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
"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#

Use it when

  • 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.

Use something else when

  • 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.
Example
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>
  );
}

API#

PropTypeDefaultDescription
childrenReactNodeThe dialog's parts: DialogTitle, DialogDescription, your content, DialogActions.
openbooleanControlled open state. Pair with onOpenChange.
defaultOpenbooleanfalseStarting open state when uncontrolled.
onOpenChange(open: boolean) => voidRuns whenever the dialog opens or closes, from the trigger, Esc, an outside press or an action.
triggerReactElementThe 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.
dismissiblebooleantrueClose on Esc and on a press outside the panel. Turn off for a decision that must be made.
iconReactNodeA mark shown in a tile above the title. Components never bake in an icon.
containerDialogContainerWhere 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.
modalboolean | "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 takes 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#

Also in the markdown page and llms.txt
  1. 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.
  2. Compose the parts: DialogTitle, DialogDescription, your content, then DialogActions with confirm and onConfirm.
  3. 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.
  4. Name the action in confirm ("Delete agent", "Connect Linear"), never "OK" or "Continue".
  5. Use variant danger for anything destructive; it styles the action with the danger tokens, puts it second and focuses Cancel.
  6. Pass container to keep the dialog inside a region of the page; it then traps focus without locking page scroll.