SuperAPI AI Directory

shadcn-syntax-dialog

Coding & development

View repository

Updated 10-15 22:18

About this skill

shadcn ui : Dialog Syntax

The Dialog component is shadcn's reference focus-trapped modal. It wraps the Radix Dialog primitive, adds default styling, and exposes a ten-part composition surface. Every dialog-shaped primitive in the catalogue (AlertDialog, Sheet, Drawer, CommandDialog, the mobile Sidebar variant) is either a thin extension of this surface or a parallel implementation following the same controlled-state and a11y contract. Mastering Dialog means mastering the whole family.

Quick Reference

The 10 primitives

Primitive Purpose
Dialog Root state container ; owns open / onOpenChange / defaultOpen / modal
DialogTrigger Element that opens the dialog ; asChild-aware
DialogPortal Renders Overlay + Content inside document.body (or container prop)
DialogOverlay Backdrop ; styled by shadcn with bg-black/50 + fade animation
DialogContent Main surface ; AUTO-WRAPS itself in DialogPortal + DialogOverlay ; exposes showCloseButton
DialogHeader Layout div for Title + Description ; pure Tailwind, no Radix part
DialogFooter Layout div for action buttons ; pure Tailwind ; exposes optional showCloseButton
DialogTitle Accessible name (REQUIRED for screen-reader compliance)
DialogDescription Accessible description ; OPTIONAL but recommended
DialogClose Closes the dialog ; asChild-aware ; wrap any button

Minimal Dialog (uncontrolled)

"use client"

import {
  Dialog, DialogTrigger, DialogContent,
  DialogHeader, DialogFooter,
  DialogTitle, DialogDescription, DialogClose,
} from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"

<Dialog>
  <DialogTrigger asChild>
    <Button>Open</Button>
  </DialogTrigger>
  <DialogContent>
    <DialogHeader>
      <DialogTitle>Are you sure?</DialogTitle>
      <DialogDescription>This will permanently delete the file.</DialogDescription>
    </DialogHeader>
    <DialogFooter>
      <DialogClose asChild>
        <Button variant="outline">Cancel</Button>
      </DialogClose>
      <Button variant="destructive">Delete</Button>
    </DialogFooter>
  </DialogContent>
</Dialog>

Notice : no open / onOpenChange props. Radix manages state internally. DialogTrigger and DialogClose update it.

Five invariants

  1. ALWAYS provide a DialogTitle. NEVER omit it ; Radix logs a dev-mode warning and axe-core flags missing accessible-name as critical.
  2. ALWAYS either control BOTH open and onOpenChange, or control NEITHER. NEVER pass open alone (the dialog becomes a frozen prop with no way to close it).
  3. ALWAYS keep the "use client" directive at the top of components/ui/dialog.tsx. NEVER strip it ; Dialog uses Radix client-only hooks and a Portal.
  4. ALWAYS render <DialogContent> directly (it wraps itself in DialogPortal + DialogOverlay). NEVER hand-wrap DialogContent in your own portal unless you are deliberately retargeting container, in which case use the explicit <DialogPortal container={el}> form.
  5. ALWAYS wrap a non-default close affordance via <DialogClose asChild> so a single child receives the click handler. NEVER place two children inside <DialogClose asChild> (Radix Slot rejects multi-child).

Decision Tree 1 : Controlled or uncontrolled?

Q1. Do you need the dialog state to MIRROR a value outside the dialog
    (e.g., a `selectedId` URL param, a Zustand store, a parent form
    state, an effect that opens the dialog on data-arrival)?
    no  -> uncontrolled. Use <Dialog> with no props.
    yes -> Q2

Q2. Are you willing to OWN both `open` and `onOpenChange` (every render
    you write must pass both, and onOpenChange must call setOpen)?
    no  -> stay uncontrolled and lift state via a ref or event callback
           on the trigger element ; do NOT pass `open` alone.
    yes -> ALWAYS pass <Dialog open={state} onOpenChange={setState}>.
           Implement onOpenChange as a STABLE setter that accepts a
           boolean and writes it to your state container.

NEVER pass open without onOpenChange. Radix treats the prop as authoritative and ignores Trigger/Close/Esc, leaving the dialog frozen in its initial open state.

NEVER pass onOpenChange without open. The callback fires but the displayed state is still uncontrolled, so your handler runs against stale closed state.

For a one-shot "default to open on mount" without ongoing control, use <Dialog defaultOpen> and skip controlled state entirely.

Decision Tree 2 : Where should DialogContent render?

Q1. Is the dialog inside a transformed ancestor (any element with
    `transform`, `filter`, `perspective`, `will-change`, or a CSS
    container query that creates a new stacking context)?
    no  -> default. <DialogContent> auto-portals to document.body.
           Nothing extra to do.
    yes -> Q2

Q2. Does the dialog NEED to be visually anchored inside the
    transformed ancestor (rare ; almost always a design mistake) ?
    no  -> default. Auto-portal escapes the stacking context and
           the dialog renders correctly on top of everything.
    yes -> Use the EXPLICIT portal form :
           <DialogPortal container={hostElement}>
             <DialogContent>...</DialogContent>
           </DialogPortal>
           and accept that the dialog is constrained to the
           ancestor's stacking context.

DialogContent already calls DialogPortal + DialogOverlay internally (verified verbatim from apps/v4/registry/new-york-v4/ui/dialog.tsx). The explicit <DialogPortal> is only required when you must override container or forceMount.

Decision Tree 3 : Hide the title visually but keep a11y?

Q1. Does the dialog need a VISIBLE title?
    yes -> <DialogTitle>Heading</DialogTitle>. Done.
    no  -> Q2

Q2. Does the dialog still need to ANNOUNCE a name to screen readers?
    (yes : every dialog does, per WAI-ARIA Dialog pattern)
    -> ALWAYS wrap DialogTitle in `sr-only` :
       <DialogTitle className="sr-only">Settings</DialogTitle>
       OR use the Radix VisuallyHidden primitive :
       <VisuallyHidden asChild><DialogTitle>Settings</DialogTitle></VisuallyHidden>
       NEVER drop DialogTitle entirely.

The sr-only className route is the shadcn-canonical pattern and what the Sidebar mobile variant uses internally (per issue #5746, 49 reactions).

The 10 primitives in detail

Dialog (Root)

Owns the state. Props :

  • open?: boolean : controlled visibility. PAIR WITH onOpenChange.
  • defaultOpen?: boolean : uncontrolled initial state. EXCLUDES open.
  • onOpenChange?: (open: boolean) => void : called when Radix wants to change visibility (Trigger click, Close click, Esc, pointer-down-outside, programmatic).
  • modal?: boolean (default true) : when true, focus is trapped and the rest of the page is aria-hidden ; when false, the page stays interactive (rarely useful).

ALWAYS choose either defaultOpen or open+onOpenChange. NEVER both ; Radix prefers the controlled prop and ignores the default.

DialogTrigger

Element that toggles open to true. Default render is a <button type="button">. Use asChild to wrap any focusable element. Receives the data-state="open|closed" attribute for styling.

<DialogTrigger asChild>
  <Button variant="outline">Edit profile</Button>
</DialogTrigger>

NEVER wrap two siblings in <DialogTrigger asChild> ; Radix Slot rejects multi-child.

DialogPortal

Renders Overlay + Content inside document.body by default. Props :

  • container?: HTMLElement : alternate render target. Useful for shadow DOM, iframe roots, deliberately-constrained scopes.
  • forceMount?: boolean : keep mounted even when open is false (lets you animate exit yourself).

DialogContent calls DialogPortal internally with no props. To customise, use the explicit form.

DialogOverlay

Backdrop. Styled by shadcn with fixed inset-0 z-50 bg-black/50 + fade animation via data-[state=open]:animate-in / data-[state=closed]:animate-out.

NEVER set pointer-events-none on the overlay : Radix needs pointer events to fire onPointerDownOutside, which closes the dialog when modal is true.

DialogContent

The main surface. Auto-portals (wraps itself in DialogPortal + DialogOverlay). Props :

  • showCloseButton?: boolean (default true) : shadcn-only addition ; toggles the built-in top-right XIcon close.
  • onOpenAutoFocus?: (event) => void : called when focus moves into the content on open. event.preventDefault() to keep focus where it was.
  • onCloseAutoFocus?: (event) => void : called when focus moves back on close.
  • onEscapeKeyDown?: (event) => void : called when Esc is pressed. event.preventDefault() to keep open.
  • onPointerDownOutside?: (event) => void : called when user clicks outside. event.preventDefault() to keep open.
  • onInteractOutside?: (event) => void : union of pointer-down-outside + focus-outside.
  • forceMount?: boolean : keep mounted for custom exit animation.

ALWAYS render exactly ONE <DialogContent> per <Dialog>. NEVER conditionally render two and toggle which is shown.

DialogHeader

Pure layout div with flex flex-col gap-2 text-center sm:text-left. Not a Radix part ; safe to omit if your layout differs.

DialogFooter

Layout div with flex flex-col-reverse gap-2 sm:flex-row sm:justify-end. shadcn evergreen-2026 adds an optional showCloseButton?: boolean (default false) that auto-renders a <DialogClose asChild><Button variant="outline">Close</Button></DialogClose> at the end of the children.

DialogTitle

REQUIRED. Renders an <h2> with the dialog's accessible name. Without it, Radix logs DialogContent requires a DialogTitle for the component to be accessible for screen reader users. and axe-core flags the dialog as missing an accessible name (rule aria-dialog-name, severity critical).

To hide visually : <DialogTitle className="sr-only">…</DialogTitle> or wrap with <VisuallyHidden asChild>.

DialogDescription

Optional supplemental text. If you intentionally omit it, ALSO pass aria-describedby={undefined} to <DialogContent> to suppress the Radix warning about a missing description element.

DialogClose

Closes the dialog. Default render is a <button type="button">. asChild to wrap any focusable element. Useful in DialogFooter for an explicit "Cancel" button.

<DialogClose asChild>
  <Button variant="outline">Cancel</Button>
</DialogClose>

a11y requirement : DialogTitle is non-optional

The WAI-ARIA Dialog pattern requires every modal dialog to have an accessible name. Radix Dialog implements this by reading <DialogTitle> and wiring it via aria-labelledby. If DialogTitle is missing :

  1. Radix logs a development warning to the console.
  2. axe-core / axe DevTools / Lighthouse a11y audit flag the dialog as critical (no accessible name).
  3. Screen-reader users hear "dialog" with no further context.

This applies to EVERY dialog, including the mobile Sidebar variant (issue #5746, 49 reactions) and CommandDialog from cmdk.

The fix is always the same : add a DialogTitle, with sr-only if invisible. See references/examples.md for the visually-hidden recipe.

Portal rendering and z-index

<DialogPortal> mounts its children inside document.body by default. This means :

  • The dialog escapes any overflow: hidden, transform, filter, or will-change ancestor that would otherwise clip it.
  • The dialog renders at the top of the DOM, so a z-50 from the shadcn DialogOverlay sits above any non-portalled z-* value below 50 in the rest of the tree.
  • Nested dialogs MUST each use their own portal scope. The inner dialog's overlay automatically stacks above the outer (later DOM order = later in document.body), as long as both go through DialogPortal.

If you need to retarget (rare) :

<DialogPortal container={hostRef.current}>
  <DialogOverlay />
  <DialogContent>…</DialogContent>
</DialogPortal>

Note : when you take over DialogPortal manually, you ALSO render <DialogOverlay /> yourself ; DialogContent only auto-wraps if you let it.

NEVER place a <DialogContent> directly without either auto-portal or explicit DialogPortal. Z-index ordering becomes ancestor-dependent and unpredictable.

RSC and the "use client" directive

The shadcn dialog.tsx ships with "use client" at the top. This is non-negotiable :

  • Radix Dialog uses React Context internally.
  • Dialog state, focus trapping, and Portal mounting all run client-side.
  • The data-state attribute toggles via React state, not server render.

ALWAYS keep "use client" in components/ui/dialog.tsx. NEVER strip it ; the file imports radix-ui which requires a client boundary.

Components that consume <Dialog> may live in Server Components, but the dialog file itself draws the client boundary. See shadcn-impl-rsc-vs-client-boundaries for the broader RSC contract.

Form-inside-dialog : close after submit

The canonical pattern when the dialog body contains a form :

const [open, setOpen] = useState(false)
function onSubmit(values) {
  await saveProfile(values)
  setOpen(false) // ALWAYS synchronously after the await
}

ALWAYS call setOpen(false) (or whatever your onOpenChange setter is) inside the success branch of the submit handler. NEVER rely on the form's submit-button being a <DialogClose> : if validation fails, <DialogClose> still fires and closes the dialog before the user sees the error.

Full recipe in references/examples.md.

Companion Skills

  • shadcn-syntax-sheet : Dialog with a side prop ; same controlled-state contract, side-panel UX, larger content. Read when the surface should slide in from an edge instead of centring.
  • shadcn-syntax-drawer : Vaul-backed bottom-sheet for mobile ; same a11y contract (DrawerTitle required) but different primitive. Read when the surface is mobile-first.
  • shadcn-impl-responsive-dialog-drawer : Dialog-on-desktop / Drawer-on-mobile recipe using useMediaQuery. Read when the same UI must adapt across viewports.
  • shadcn-errors-radix-controlled : the controlled-state failure catalogue across all Radix primitives, including open-without-onOpenChange, Slot multi-child crashes, and Portal stacking-context surprises.
  • shadcn-syntax-button : the asChild pattern used by DialogTrigger and DialogClose.

Reference Links

  • references/methods.md : full prop signatures for all ten primitives, controlled-state pair, showCloseButton toggle, data-attributes.
  • references/examples.md : six canonical recipes (uncontrolled, controlled, form-in-dialog, sr-only title, custom portal container, asChild DialogClose).
  • references/anti-patterns.md : six anti-patterns with WHY and FIX.

Sources

All claims in this skill trace to URLs in SOURCES.md :

Verified 2026-05-19.