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.
Install#
npx shadcn@latest add @pixilui/dialogOnce per app, add the registry to 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
- 1
Install the npm packages.
npm install @base-ui/react motion clsx tailwind-merge - 2
Add the items it depends on, the same way.
- 3
Copy each file to the path shown.
"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> ); }); - 4
Check that your global stylesheet imports the foundation, after Tailwind.
@import "tailwindcss"; @import "../components/pixilui/foundation.css";
Adds components/pixilui/dialog.tsx. Needs @base-ui/react, motion, clsx, tailwind-merge.
Preview#
The panel blooms out of a 2px blur on the morph spring, and a danger dialog lands focus on Cancel, never on Delete.
Code#
"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.
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' 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#
| 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 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
- 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.