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 usva

layer

core / primitives

intensity

recedes · safe anywhere, including dense surfaces

composition

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 much

a11y

role="dialog" named by its Title, or aria-label without one · focus moves in on open, Escape closesjest-axe

dependencies

@base-ui/react · Card from the same package
live 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
usagecopy
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

proptypedefaultnotes
openbooleancontrolled open state, on the root.
defaultOpenbooleanfalseinitial open state when uncontrolled.
onOpenChange(open, eventDetails) => voidfires when the open state changes.
modalboolean | "trap-focus"truefocus 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.
backdropClassNamestringextra classes on the scrim, on Dialog.Content.

get it

npx shadcn add https://usva.build/r/dialog.jsoncopy
source · components/ui/dialog.tsxexactly what this command copiescopy
"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,
});