Components

Dialog

Centered modal overlay for a short, blocking decision or a small focused form. Built on Radix Dialog with Yuno-token chrome (Header + Body + Footer stacked, bg-background, shadow-lg, rounded-lg, dimmer bg-foreground/24). Uses the same 3-region structure as Sheet.

Updated Jul 21, 2026 by Leonardo Posada
Not sure this is the right surface?
Dialog, Sheet, Drawer and full-screen views compete for similar jobs. Read the decision guide to pick the right one for your case. Overlays & full-screen guide.

Anatomy

Five parts. Trigger and Content, plus three composable regions inside Content: Header (bordered top row with the title + optional description + close X), Body (optional middle region for form fields or content), Footer (bordered bottom row with the actions). Content stacks all three vertically, borders separate them.

  1. 1
    Trigger

    Any focusable element. Pass asChild so the trigger wraps YOUR element (usually a Button) instead of adding its own.

  2. 2
    Overlay

    Fixed dimmer over the app: bg-foreground/24, no blur. Applied automatically by DialogContent — never render manually.

  3. 3
    Content

    The centered card: bg-background, border, rounded-lg (10px), shadow-lg (composite). Defaults to sm:max-w-md (~448px); override className up to max-w-7xl (~1280px, Figma cap).

  4. 4
    DialogHeader

    Top row with a bottom border. Ships flex flex-col gap-1.5 border-b p-6 pr-14 text-left. Reserves 56px of right padding for the close X. Contains DialogTitle + optional DialogDescription.

  5. 5
    DialogBody

    Required in the Yuno convention. Ships flex-1 overflow-auto border-b p-6. Owns the divider ABOVE the Footer (border-b). Every Dialog has a Body — form fields, confirmation copy ('Are you sure...'), or explanation text. Never leave it out even for confirmations.

  6. 6
    DialogFooter

    Bottom row WITHOUT its own border. Ships flex flex-col-reverse gap-2 p-6 sm:flex-row sm:justify-end. The divider above it comes from DialogBody (or DialogHeader when Body is absent) — this avoids a double 2px line when Header + Footer sit adjacent. Order Cancel then Save in code — on desktop they render left-to-right; on mobile the Primary rises to the top of the stack (Yuno convention).

  7. 7
    DialogTitle

    Required by Radix for accessibility. text-lg (18px) font-semibold leading-none. Keep short — the header truncates.

  8. 8
    DialogDescription

    Optional. text-sm text-muted-foreground with leading-normal (20px). Rare in the Dashboard — the 99% pattern is title-only. Only add when the title alone doesn't carry the context.

  9. 9
    Close X

    Absolute-positioned Phosphor X at right-6 top-6 (aligned with the p-6 Header). weight='light', size-4, opacity-70 (100 on hover). Present by default; not toggleable.

Variants

Not variants — layout patterns you compose. Same three regions (Header + Body + Footer), different content per region. Every Yuno Dialog always has all three — Body carries the substance whether that's a form, a confirmation question, or explanation text.

Canonical (Header + Body + Footer)
<Dialog>
  <DialogTrigger asChild>
    <Button>Create API key</Button>
  </DialogTrigger>
  <DialogContent>
    <DialogHeader>
      <DialogTitle>Create API key</DialogTitle>
    </DialogHeader>
    <DialogBody>
      <div className="grid gap-4">
        <div className="grid gap-1.5">
          <Label htmlFor="key-name">Name</Label>
          <Input id="key-name" placeholder="Production" />
        </div>
        <div className="grid gap-1.5">
          <Label htmlFor="key-scope">Scope</Label>
          <Input id="key-scope" placeholder="Read + write" />
        </div>
      </div>
    </DialogBody>
    <DialogFooter>
      <DialogClose asChild>
        <Button variant="outline">Cancel</Button>
      </DialogClose>
      <Button>Create key</Button>
    </DialogFooter>
  </DialogContent>
</Dialog>
Multi-step (back arrow in Header)

Interactive example. Open, walk from step 1 to step 2, and watch the back arrow appear on the second step. Same DialogContent instance, DialogBody swaps content between steps, step indicator lives in the Footer.

function MultiStepDialogExample() {
  const [step, setStep] = React.useState(1);
  const total = 2;
  const isFirst = step === 1;
  const isLast = step === total;
  const titles = ["Refund", "Confirm"];
  return (
    <Dialog onOpenChange={(open) => { if (!open) setStep(1); }}>
      <DialogTrigger asChild>
        <Button>Open multi-step dialog</Button>
      </DialogTrigger>
      <DialogContent>
        <DialogHeader className="flex-row items-center gap-3">
          {/* Back arrow ONLY on step 2+ — not on step 1 */}
          {!isFirst && (
            <Button
              variant="ghost"
              size="icon"
              className="-ml-2 size-8"
              onClick={() => setStep((s) => s - 1)}
              aria-label="Back"
            >
              <ArrowLeft weight="light" className="size-4" />
            </Button>
          )}
          <DialogTitle>{titles[step - 1]}</DialogTitle>
        </DialogHeader>
        <DialogBody>{/* per-step content */}</DialogBody>
        <DialogFooter className="sm:items-center sm:justify-between">
          <span className="text-xs font-medium text-muted-foreground">
            Step {step} of {total}
          </span>
          <div className="flex flex-col-reverse gap-2 sm:flex-row sm:items-center sm:gap-2">
            <DialogClose asChild>
              <Button variant="outline">Cancel</Button>
            </DialogClose>
            {isLast ? (
              <DialogClose asChild>
                <Button onClick={() => setStep(1)}>Issue refund</Button>
              </DialogClose>
            ) : (
              <Button onClick={() => setStep((s) => s + 1)}>Next</Button>
            )}
          </div>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  );
}
Back arrow appears from step 2 onward
Render the ArrowLeft button conditionally inside the DialogHeader — visible on steps 2+ and hidden on step 1 (there's nothing to go back to). Place it as the first child of DialogHeader with className='-ml-2 size-8' so it hugs the left edge without pushing the title. Use className='flex-row items-center gap-3' on DialogHeader to lay the arrow and title inline.

Width

Default is sm:max-w-md (~448px) — matches the Figma canonical of 426px. For wider Dialogs override className with sm:max-w-lg / xl / 2xl up to max-w-7xl (~1280px, the Figma-documented Yuno cap). Never exceed max-w-7xl — beyond that, the surface stops feeling like a Dialog and should be a Sheet or a full-screen page.

States

Radix drives open/closed via data-state. Content fades + zooms in (zoom-in-95, 200ms). Overlay fades in with the same timing. On close both reverse. No custom motion needed — the defaults are already Yuno.

Motion

Dialog appears centered with a soft zoom + fade so the eye lands in the middle of the screen. All values map to Yuno tokens.

Enter animationfade-in-0 + zoom-in-95 (200ms)
Exit animationfade-out-0 + zoom-out-95 (200ms)
Overlayfade-in-0 / fade-out-0 (200ms)
Overlay tintbg-foreground/24 (Yuno dimmer, no blur)
Radiusrounded-lg (10px)
Shadowshadow-lg (composite 2-layer)
Every Yuno Dialog has a Body
Header + Body + Footer, always. Even confirmations. The 'Are you sure you want to delete this?' question and the consequence ('This cannot be undone.') go in the Body — never crammed into the Header. Confirmation-only Dialogs (Header + Footer with buttons and nothing else) are not used at Yuno.
Title-only is the 99% pattern
Just like Sheet, the Dashboard convention is title-only in the header. DialogDescription exists (Radix requires it for accessibility if the visual title alone isn't enough context), but reach for it only when the title genuinely doesn't carry the meaning. The substance of the Dialog belongs in the DialogBody — not stuffed into the header via DialogDescription.
Never used at Yuno: variant='destructive' Button
Even for destructive confirmations (Delete rule, Delete list, Delete API key), the primary action button stays Primary (indigo) — never red destructive. The weight comes from the copy ('Delete rule?'), not from the color. Yuno reserves red for actual system alerts and errors.

Recipes

Ready-to-copy compositions covering the most common Yuno usages of this atom.

Confirmation (canonical)

Short blocking decision. Title-only header — the title copy carries the weight ('Delete API key?'). No Body. Footer has Cancel (outline, left) + Primary action (right). Uses Primary — never destructive red.

<Dialog>
  <DialogTrigger asChild>
    <Button variant="outline">Delete API key</Button>
  </DialogTrigger>
  <DialogContent>
    <DialogHeader>
      <DialogTitle>Delete API key</DialogTitle>
    </DialogHeader>
    <DialogBody>
      <p className="text-sm text-foreground/85">
        Are you sure you want to delete this API key? Any integrations
        using it will stop working immediately and this action cannot be
        undone.
      </p>
    </DialogBody>
    <DialogFooter>
      <DialogClose asChild>
        <Button variant="outline">Cancel</Button>
      </DialogClose>
      <Button>Delete key</Button>
    </DialogFooter>
  </DialogContent>
</Dialog>
Quick create (small form)

1 to 3 field create form. Header holds the title only. Body wraps the fields. Footer has Cancel + Create. If the form grows past 3 fields, switch to Sheet — the Dialog will feel cramped.

<Dialog>
  <DialogTrigger asChild>
    <Button>Invite user</Button>
  </DialogTrigger>
  <DialogContent>
    <DialogHeader>
      <DialogTitle>Invite user</DialogTitle>
    </DialogHeader>
    <DialogBody>
      <div className="grid gap-4">
        <div className="grid gap-1.5">
          <Label htmlFor="invite-email">Email</Label>
          <Input id="invite-email" placeholder="jane@company.com" />
        </div>
        <div className="grid gap-1.5">
          <Label htmlFor="invite-role">Role</Label>
          <Input id="invite-role" placeholder="Admin" />
        </div>
      </div>
    </DialogBody>
    <DialogFooter>
      <DialogClose asChild>
        <Button variant="outline">Cancel</Button>
      </DialogClose>
      <Button>Send invite</Button>
    </DialogFooter>
  </DialogContent>
</Dialog>
With icon in Header

For status-carrying Dialogs (Info / Warning), place a Phosphor icon (weight='light') to the left of the title inside the Header row. Use text-primary for informational, text-destructive for warnings. Do NOT tint the whole Dialog — the icon is enough.

<DialogContent>
  <DialogHeader className="flex-row items-center gap-3">
    <Info weight="light" className="size-4 shrink-0 text-primary" />
    <DialogTitle>Read-only mode</DialogTitle>
  </DialogHeader>
  <DialogBody>
    <p className="text-sm text-foreground/85">
      Your current role can view rules but not edit them. Contact an admin
      if you need write access to this workspace.
    </p>
  </DialogBody>
  <DialogFooter>
    <DialogClose asChild>
      <Button>Got it</Button>
    </DialogClose>
  </DialogFooter>
</DialogContent>
With provider logo in Header

For provider-scoped Dialogs (Connect Stripe, Configure Adyen, Setup dLocal), place the provider logo from the Yuno CDN inline with the title. Sized at size-6 (24px, bigger than a Phosphor icon because logos are brand marks). Load from https://icons.prod.y.uno/{slug}_logosimbolo.png — verified slugs: stripe / adyen / dlocal / payu / mercadopago / 2c2p / visa / mastercard / amex. Never tint the logo.

<DialogContent>
  <DialogHeader className="flex-row items-center gap-3">
    {/* Provider logo from Yuno CDN */}
    <img
      src="https://icons.prod.y.uno/stripe_logosimbolo.png"
      alt="Stripe"
      className="size-6 shrink-0 object-contain"
    />
    <DialogTitle>Connect Stripe</DialogTitle>
  </DialogHeader>
  <DialogBody>
    <p className="text-sm text-foreground/85">
      Enter your Stripe secret key to route payments through Stripe.
    </p>
  </DialogBody>
  <DialogFooter>
    <DialogClose asChild><Button variant="outline">Cancel</Button></DialogClose>
    <Button>Connect</Button>
  </DialogFooter>
</DialogContent>
With description (rare)

Adding a DialogDescription is supported but rare — the Dashboard convention is title-only in the header. Reach for a description only when the title alone doesn't carry the context. Body is still required (Yuno convention: every Dialog has a Body).

<DialogContent>
  <DialogHeader>
    <DialogTitle>Dialog title</DialogTitle>
    <DialogDescription>
      Short helper text that complements the title.
    </DialogDescription>
  </DialogHeader>
  <DialogBody>
    {/* content */}
  </DialogBody>
  <DialogFooter>
    <DialogClose asChild><Button variant="outline">Cancel</Button></DialogClose>
    <Button>Save</Button>
  </DialogFooter>
</DialogContent>

Import

The full compound. DialogBody and DialogDescription are optional — most Dashboard Dialogs skip DialogDescription and only include DialogBody when there's form content.

The full compound. DialogBody and DialogDescription are optional — most Dashboard Dialogs skip DialogDescription and only include DialogBody when there is form content.
import {
  Dialog,
  DialogBody,
  DialogClose,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
  DialogTrigger,
} from "@/components/ui/dialog";

Props

Everything else from Radix Dialog is forwarded via ...props. Refer to Radix's Dialog docs for the low-level primitive props.

PropTypeDefaultDescription
Dialog.open / onOpenChangeboolean / (open: boolean) => voidControlled open state. Omit both for uncontrolled — Radix handles it. Use for programmatic open (from a menu action) and to reset internal state (multi-step step) when the dialog closes.
DialogTrigger.asChildbooleanfalseMerges Radix's trigger props onto the child element instead of rendering a button. Standard shadcn pattern for using your own Button.
DialogContent.classNamestringMerged onto the centered card. Use for width overrides (sm:max-w-lg / xl / 2xl up to max-w-7xl). Don't override the padding — DialogHeader / DialogBody / DialogFooter handle it.
DialogHeadercomponentThe bordered top row. Ships flex flex-col gap-1.5 border-b p-6 pr-14 text-left with pr-14 reserved for the close X. Contains DialogTitle + optional DialogDescription. Use className='flex-row items-center gap-3' when composing a back arrow + title inline.
DialogBodycomponentRequired by Yuno convention (technically optional in the source). Ships flex-1 overflow-auto border-b p-6. Owns the divider ABOVE the Footer via border-b. Every Dialog has a Body — form fields, confirmation copy ('Are you sure...'), or explanation text. Never skip it, even for confirmations.
DialogFootercomponentBottom row without its own border. Ships flex flex-col-reverse gap-2 p-6 sm:flex-row sm:justify-end. The divider above comes from DialogBody's border-b. Primary CTA goes on the right (desktop); on mobile buttons stack full-width with primary on top (write Cancel first, Primary second).
DialogTitlecomponentRequired by Radix for accessibility. text-lg font-semibold leading-none. Keep short — the header truncates.
DialogDescriptioncomponentOptional and rare in the Dashboard. text-sm text-muted-foreground with leading-normal. Only add when the title alone doesn't carry the context.
DialogClose.asChildbooleanfalseMerges close behavior onto your own Button. Standard pattern for the Cancel button in the footer.

When to use

  • Short blocking decisions (Delete rule?, Discard changes?).
  • Quick create forms of 1 to 3 fields — anything bigger goes to Sheet.
  • Multi-step guided decisions with 2 to 3 small steps.
  • Acknowledgement-required notifications the user must dismiss explicitly.

When not to use

  • Forms with more than 3 fields — use Sheet.
  • Object detail views — use Sheet or a full-screen route.
  • Non-blocking feedback — use Toast.
  • Contextual info triggered by hover — use Tooltip or Popover.

Usage

Do
  • State the action clearly in the title ('Delete API key', 'Discard changes').
  • Always include a DialogBody — even for confirmations. Put the 'Are you sure...' copy and the consequence there ('This cannot be undone').
  • Provide Cancel + Primary action — order them Cancel then Primary in code so mobile stacks Primary on top.
  • For destructive confirmations, use the Primary button (not variant='destructive') — clarity comes from the copy.
Don't
  • Don't skip DialogBody. Every Yuno Dialog has a Body — even confirmations put the question and consequences there.
  • Don't use variant='destructive' — Yuno reserves red for real errors.
  • Don't stack Dialogs on top of Dialogs. The only legal stack is Dialog on top of Sheet (see the Overlays guide).
  • Don't blur the overlay — Yuno uses a flat dimmer (bg-foreground/24).
  • Don't add a DialogDescription just because you can — 99% of Dashboard Dialogs are title-only.

Related

Cross-links to atoms and patterns you may reach for next.

  • SheetFor medium/long forms (create payment link, edit rule) — Dialog feels cramped past 3 fields.
  • Alert dialogFor truly destructive confirmations that require explicit acknowledgement — Radix enforces a stricter modal contract.
  • DrawerMobile bottom sheet with drag-to-dismiss. Reach for it in mobile-only surfaces, not the desktop Dashboard.
  • Overlays & full-screenThe decision guide — read this before picking Dialog over Sheet or Drawer.