Skip to content
Component installs need the registry setup
VegaStack Design

Dialog

A modal overlay — a backdrop, a centred popup, an optional corner close button and a plain, right-aligned footer that stays put while the body scrolls.

Status
stable
Since
0.1.0
Accessibility pattern
APG dialog (modal)

Last updated

Install

Configure registry credentials first using the Quickstart. Before copying this item or any transitive registry dependency, verify the signed manifest and save the exact bytes for Dialog before installation. Retain the digest printed by this command.

pnpm exec vegastack-design verify dialog

Install with the pinned shadcn CLI. Bare shadcn does not verify VegaStack signature or integrity.

pnpm dlx shadcn@4.21.0 add @vegastack/dialog

Run the exact offline --post-write command printed by the preflight, using its saved item path and independently retained --expected-integrity digest. Use the saved preflight for every transitive registry dependency too; stop on any mismatch before using the components.

The same command installs the registry items it composes: @vegastack/button, @vegastack/use-modal-inert.

Usage

import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogHeader,
  DialogTitle,
  DialogTrigger,
} from "@/components/ui/dialog";

<Dialog>
  <DialogTrigger>Open</DialogTrigger>
  <DialogContent>
    <DialogHeader>
      <DialogTitle>Are you absolutely sure?</DialogTitle>
      <DialogDescription>
        This action cannot be undone. This will permanently delete your account
        and remove your data from our servers.
      </DialogDescription>
    </DialogHeader>
  </DialogContent>
</Dialog>;

Anatomy

Dialog — data-slot="dialog"
DialogBody — data-slot="dialog-body"
DialogClose — data-slot="dialog-close"
DialogContent — data-slot="dialog-close" | "dialog-content"
DialogDescription — data-slot="dialog-description"
DialogFooter — data-slot="dialog-footer"
DialogHeader — data-slot="dialog-header"
DialogOverlay — data-slot="dialog-overlay"
DialogPortal — data-slot="dialog-portal"
DialogTitle — data-slot="dialog-title"
DialogTrigger — data-slot="dialog-trigger"

Examples

Composition

DialogContent renders the portal and the backdrop for you, so the tree below is the whole component: a trigger beside a content, and inside it a header and a footer.

Dialog
├── DialogTrigger
└── DialogContent
    ├── DialogHeader
    │   ├── DialogTitle
    │   └── DialogDescription
    └── DialogFooter

Custom Close Button

DialogClose closes the dialog from anywhere inside it. Give it a render prop and it becomes that control — here a footer button — while keeping the close behaviour.

No Close Button

showCloseButton={false} on DialogContent removes the corner close control. DialogFooter has its own showCloseButton, off by default, which appends a secondary Close button to the footer — so a dialog without the corner control still offers a visible way out.

DialogFooter is as plain as the header — no background, no divider, the popup's own padding — with its actions right-aligned and secondary actions (Cancel, Back) on variant="secondary" (OVL-18). Put the scroll container between the header and the footer and the actions stay visible while the body scrolls.

Scrollable Content

The popup itself does not scroll — it is a grid that grows with its children. Long content goes in its own overflow-y-auto element so the header stays in view.

Size

size on DialogContent steps the width cap from the sm breakpoint up. The value is published as data-size, the same axis AlertDialogContent carries. Below sm every size fills the viewport less a 1rem gutter.

SizeCapUse it for
smmax-w-xs (320px)A one-field prompt
defaultmax-w-sm (384px)A short form or a confirmation with a little context
mdmax-w-lg (512px)Share and settings dialogs: people rows with a level menu, tabs
lgmax-w-2xl (672px)A two-column form, a long create flow
xlmax-w-5xl (1024px)A bulk-edit table or an attribute editor
fullscreenThe viewport less a 16px inset (none on a phone)A full-screen surface — a document's version history beside its text

fullscreen is also a height: the popup is h-[calc(100dvh-2rem)], a flex column whose header and footer stay put while a DialogBody (or a VersionCompare) between them scrolls. Below sm it is the whole screen, with no border or radius, padded clear of the safe areas.

RTL

The popup's position, its close button and its inner padding are written in logical properties, so a right-to-left document mirrors the whole surface; the -translate-x-1/2 that centres it is not logical, so it carries an explicit rtl: counterpart. Two things the caller owns: wrap the subtree in DirectionProvider so Base UI's own logic agrees with the document's dir, and hand dir to DialogContent — the popup portals to <body>, so a dir on an ancestor of the trigger never reaches it.

Scrolling body

DialogBody is the scroller for long content (min-h-0 flex-1 overflow-y-auto, with -mx-4 px-4 so focus outlines are not clipped at the popup's padding). A popup that holds one becomes a flex column capped 1rem inside the viewport, so the header and the footer stay in view while the body scrolls. A dialog without a DialogBody keeps upstream's growing grid.

Long names

A dialog that carries a user-entered name — a product, a task, a file — keeps every part inside the popup. DialogContent lays out on one minmax(0, 1fr) column, so a control that does not wrap (a select showing a long value) cannot widen the popup past its cap, and the popup breaks a long word with no spaces rather than letting it run past the edge. A title that wraps sits on snug lines, and while the corner close × shows, DialogHeader keeps clear of it, so the title wraps before the ×. To show a picked name whole instead of cutting it off, give the picker wrap (Searchable Select, Select). A flex row that must shrink below its longest word — a title beside a hint icon — still needs min-w-0 on the shrinking child.

Playground

Retune the content's max-width cap and either close button, then copy the generated JSX. The dialog renders closed; open it from the trigger.

<Dialog>
  <DialogTrigger render={<Button variant="outline">Open dialog</Button>} />
  <DialogContent>
    <DialogHeader>
      <DialogTitle>Delete project</DialogTitle>
      <DialogDescription>This action cannot be undone.</DialogDescription>
    </DialogHeader>
    <DialogFooter>
      <DialogClose render={<Button variant="secondary">Cancel</Button>} />
      <Button variant="destructive">Delete</Button>
    </DialogFooter>
  </DialogContent>
</Dialog>

API Reference

Dialog adds no props of its own — it accepts everything the underlying element or Base UI primitive accepts (className, ref, ARIA attributes, event handlers).

PropTypeDefaultDescription
closeLabelstring—
showCloseButtonboolean—
size"default" | "fullscreen" | "lg" | "md" | "sm" | "xl"—
PropTypeDefaultDescription
closeLabelstring—
showCloseButtonboolean—

closeLabel (default "Close") names the built-in close control on both DialogContent (the corner button) and DialogFooter (its opt-in secondary button).

DialogTrigger, DialogClose, DialogPortal, DialogOverlay, DialogHeader, DialogBody, DialogTitle and DialogDescription add no props of their own — each accepts everything its Base UI part or its div accepts.

Width is the size prop on DialogContent (OVL-16). At size="default" the cap is a plain sm:max-w-sm, so a one-off className="sm:max-w-md" still overrides it; prefer a size step.

Accessibility

  • Renders Base UI's Dialog: the popup is role="dialog" with aria-modal, wired to DialogTitle through aria-labelledby and to DialogDescription through aria-describedby. Esc closes, and focus is trapped inside the popup and returned to the trigger.
  • Give every dialog a DialogTitle. Without one the popup has no accessible name; if the title must not be seen, keep it and add className="sr-only".
  • The background is made inert natively, not just focus-trapped (A11Y-9), so a focus guard that hands focus to <body> cannot tab back into the page behind. With modal={false} the background stays interactive and nothing is made inert, which is the point of that mode.
  • The popup portals to <body>, so DialogPortal re-applies the enclosing theme scope on a display: contents host inside the portal (OVL-13) — a scoped or dark surface survives the jump.
  • The corner close button is an icon-only Button carrying a sr-only label, "Close" unless closeLabel names it.
ContractStates tested
Behaviourdefault, open, closed, modal, non-modal
Accessibilitynative-or-base-ui-semantics, browser-accessibility-test, keyboard-navigation
Visualdefault, open, closed, hover, focus

Do / Don't

Do
Use a Dialog for a focused task the user opts into — editing a record, confirming a share link — and always give it a DialogTitle.
Don't
Use a Dialog for a message that needs no decision, or nest one inside another; a toast or an inline panel is the right surface.

Deviations

Upstream's file plus packages/ui/upstream/patches/dialog.patch. Every hunk:

  • OVL-23 — DialogOverlay sets Base UI's forceRender, so a dialog opened from inside another dialog or a sheet dims and blurs the page too; Base UI skips a nested backdrop otherwise. Operator decision D31 (Regent), 2026-10-30.
  • FOC-13 — DialogContent passes an overridable initialFocus that picks the first field or control, never the close ×, else the popup itself, so opening a dialog never lights up the ×.
  • OVL-13 — DialogPortal reads useInternalThemeScope() and wraps its children in a display: contents element carrying that class, so a nested theme scope survives the jump to <body>. The wrapper generates no box, so nothing about the backdrop's or the popup's fixed positioning changes. tooling/verify-portal-theme-scope.mjs inventories this host.
  • A11Y-9 — DialogContent mirrors Base UI's live data-base-ui-inert markers onto the native inert property through useModalInert, so a focus guard that hands focus to <body> cannot tab back into the background. The root now publishes its modal prop through a context, which is exactly the shape upstream's own Drawer uses, because native inert would also block the outside POINTER interaction that modal={false} and "trap-focus" keep.
  • BRD-1 — DialogContent draws a real 1px border border-border instead of upstream's ring-1 ring-foreground/10 box-shadow outline. A border survives forced colours and matches Elevation; nothing else about the surface moves.
  • OVL-16 — DialogContent takes size?: "sm" | "default" | "md" | "lg" | "xl" | "fullscreen" (default "default") and reflects it as data-size, the axis AlertDialogContent already carries. default keeps upstream's sm:max-w-sm unconditionally; sm, md, lg and xl step it to max-w-xs, max-w-lg, max-w-2xl and max-w-5xl through data-[size=…]. Approved by MK 23-09-2026 for a consumer gap; md added 03-10-2026 for share and settings dialogs. fullscreen (MK 27-10-2026, Regent product-cohesion W1-DS3, UXP-14) fills the viewport less a 16px inset (none on a phone), h-[calc(100dvh-2rem)], with the header and footer pinned and the body scrolling.
  • OVL-18 — DialogFooter drops upstream's -mx-4 -mb-4 rounded-b-xl border-t bg-muted/50 p-4 band: it is as plain as the header, right-aligned from sm and stacked below it. Its opt-in close is variant="secondary". MK, 2026-09-25.
  • API-21 — DialogBody scrolls between the header and the footer; a popup holding one becomes a flex column capped at max-h-[calc(100%-2rem)], and a dialog without one is untouched. closeLabel renames the corner close control and the footer's opt-in close.
  • VOI-1 — both built-in close strings are the overridable closeLabel, default "Close".
  • LAY-17 — DialogContent takes grid-cols-1 (one minmax(0, 1fr) column) in place of the grid's implicit auto column, so a no-wrap child cannot widen the popup, and wrap-break-word, so a long unbroken word breaks inside it. While the corner × renders, the popup gives DialogHeader pe-7, so a long title wraps before the × instead of running under it. MK, 2026-10-13.
  • TYP-19 — DialogTitle is leading-snug, not leading-none, so a wrapped title's lines do not collide. MK, 2026-10-13.
  • DOC-2 — import { cn } from "cn" becomes @vegastack/design; our npm layer owns cn.
  • DOC-1 — the repository formatter reflows the file and registry-stamp.mjs writes the provenance header the three-copy model needs; those hunks carry no decision of their own.

On this page