docs / core / dialog
Dialog
a window that stops the page until you answer it. for confirmations, short forms, and the last word before something destructive happens.
intensity · recedesrsc · client
provenance
authored in usvalayer
core / primitivesintensity
recedes · safe anywhere, including dense surfacescomposition
confirmations and short forms that block the page until answereddestructive actions pass through it before anything is destroyednot for content that scrolls or pins to an edge. that is Drawernever stack two. a second dialog means the first asked too mucha11y
role="dialog" named by its Title, or aria-label without one · focus moves in on open, Escape closesjest-axedependencies
@base-ui/react · Card from the same packagelive demo · try it out
customize
surfacehow content sits above the scrim
highlightwash, top edge, or glow ring
modalfocus trap, scroll lock, outside-pointer
titlenames the dialog for screen readers
descriptionthe body copy
confirmadd an accent button beside cancel
usage
import { Dialog } from "usva/primitives/dialog";
<Dialog>
<Dialog.Trigger>Open dialog</Dialog.Trigger>
<Dialog.Content>
<Dialog.Title>Confirm action</Dialog.Title>
<Dialog.Description>This can't be undone. Are you sure you want to continue?</Dialog.Description>
<div className="mt-4 flex justify-end gap-2">
<Dialog.Close>Cancel</Dialog.Close>
<button type="button">Confirm</button>
</div>
</Dialog.Content>
</Dialog>props
| prop | type | default | notes |
|---|---|---|---|
| open | boolean | — | controlled open state, on the root. |
| defaultOpen | boolean | false | initial open state when uncontrolled. |
| onOpenChange | (open, eventDetails) => void | — | fires when the open state changes. |
| modal | boolean | "trap-focus" | true | focus trap, scroll lock and outside-pointer behavior in one switch. |
| surface | "elevated" | "flat" | "glass" | "elevated" | how Dialog.Content sits above the scrim. shared with Card and Drawer. |
| highlight | "none" | "wash" | "edge" | "ring" | "none" | accent treatment on Dialog.Content: a radial wash, a top edge hairline, or a glow ring. the same vocabulary as Card. |
| backdropClassName | string | — | extra classes on the scrim, on Dialog.Content. |
get it
npx shadcn add https://usva.build/r/dialog.jsonsource · components/ui/dialog.tsxexactly what this command copies
"use client";
import { Dialog as Base } from "@base-ui/react/dialog";
import * as React from "react";
import { cn } from "@/lib/utils";
import {
type CardHighlight,
type CardSurface,
SURFACE_ELEVATED,
SURFACE_SKIN,
} from "./card";
import { useScopedTheme } from "./use-scoped-theme";
export type DialogProps = Base.Root.Props;
const DialogModalContext = React.createContext<boolean | "trap-focus">(true);
function DialogRoot({ modal = true, ...props }: DialogProps) {
return (
<DialogModalContext.Provider value={modal}>
<Base.Root modal={modal} {...props} />
</DialogModalContext.Provider>
);
}
DialogRoot.displayName = "Dialog";
export const DialogTrigger = Base.Trigger;
export type DialogContentProps = React.ComponentPropsWithoutRef<
typeof Base.Popup
> & {
backdropClassName?: string;
/** How the modal sits above the scrim. Defaults to elevated. */
surface?: CardSurface;
/** Accent treatment, shared with Card: a wash, a top edge, or a glow ring. */
highlight?: CardHighlight;
};
export const DialogContent = React.forwardRef<
HTMLDivElement,
DialogContentProps
>(
(
{
className,
backdropClassName,
surface = "elevated",
highlight = "none",
children,
...props
},
ref,
) => {
const modal = React.useContext(DialogModalContext);
const [probe, scopedTheme] = useScopedTheme();
return (
<>
<span ref={probe} hidden />
<Base.Portal>
{modal === true ? (
<Base.Backdrop
data-theme={scopedTheme}
className={cn(
"fixed inset-0 z-overlay transition-opacity duration-base motion-reduce:transition-none",
"data-[starting-style]:opacity-0 data-[ending-style]:opacity-0",
surface === "glass"
? "bg-scrim/40 backdrop-blur-[3px]"
: "bg-scrim backdrop-blur-sm",
backdropClassName,
)}
/>
) : null}
<Base.Popup
ref={ref}
data-theme={scopedTheme}
data-surface={surface}
data-highlight={highlight !== "none" ? highlight : undefined}
className={cn(
"fixed left-1/2 top-1/2 z-overlay w-full max-w-md -translate-x-1/2 -translate-y-1/2",
"isolate rounded-2xl border border-border p-6 text-ink",
SURFACE_SKIN[surface],
highlight === "ring"
? "glow-ring"
: SURFACE_ELEVATED[surface] && "shadow-overlay",
highlight === "wash" && "wash-accent",
highlight === "edge" &&
"after:absolute after:inset-x-6 after:top-0 after:h-px after:hairline-accent",
"transition-enter duration-[350ms] ease-spring motion-reduce:transition-none motion-reduce:transform-none",
"data-[starting-style]:opacity-0 data-[starting-style]:blur-[4px] data-[starting-style]:scale-[0.96] data-[starting-style]:-translate-x-1/2 data-[starting-style]:-translate-y-[calc(50%-0.75rem)]",
"data-[ending-style]:opacity-0 data-[ending-style]:scale-[0.98] data-[ending-style]:-translate-x-1/2 data-[ending-style]:-translate-y-[calc(50%-0.25rem)] data-[ending-style]:duration-base data-[ending-style]:ease-soft",
className,
)}
{...props}
>
{children}
</Base.Popup>
</Base.Portal>
</>
);
},
);
DialogContent.displayName = "DialogContent";
export const DialogTitle = React.forwardRef<
HTMLHeadingElement,
React.ComponentPropsWithoutRef<typeof Base.Title>
>(({ className, ...props }, ref) => (
<Base.Title
ref={ref}
className={cn(
"text-balance text-lg font-semibold tracking-[-0.01em] text-ink",
className,
)}
{...props}
/>
));
DialogTitle.displayName = "DialogTitle";
export const DialogDescription = React.forwardRef<
HTMLParagraphElement,
React.ComponentPropsWithoutRef<typeof Base.Description>
>(({ className, ...props }, ref) => (
<Base.Description
ref={ref}
className={cn("text-pretty text-sm text-muted", className)}
{...props}
/>
));
DialogDescription.displayName = "DialogDescription";
export const DialogClose = React.forwardRef<
HTMLButtonElement,
React.ComponentPropsWithoutRef<typeof Base.Close>
>(({ className, ...props }, ref) => (
<Base.Close
ref={ref}
className={cn(
"inline-flex items-center justify-center rounded-md text-muted outline-none",
"transition-control duration-fast ease-soft",
"hover:bg-surface-2 hover:text-ink active:scale-[0.96] focus-visible:ring-focus",
"motion-reduce:transition-none motion-reduce:transform-none",
className,
)}
{...props}
/>
));
DialogClose.displayName = "DialogClose";
export const Dialog = Object.assign(DialogRoot, {
Trigger: DialogTrigger,
Content: DialogContent,
Title: DialogTitle,
Description: DialogDescription,
Close: DialogClose,
});