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.
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.
- 1Trigger
Any focusable element. Pass asChild so the trigger wraps YOUR element (usually a Button) instead of adding its own.
- 2Overlay
Fixed dimmer over the app: bg-foreground/24, no blur. Applied automatically by DialogContent — never render manually.
- 3Content
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).
- 4DialogHeader
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.
- 5DialogBody
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.
- 6DialogFooter
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).
- 7DialogTitle
Required by Radix for accessibility. text-lg (18px) font-semibold leading-none. Keep short — the header truncates.
- 8DialogDescription
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.
- 9Close 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.
<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>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>
);
}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 animation | fade-in-0 + zoom-in-95 (200ms) |
|---|---|
| Exit animation | fade-out-0 + zoom-out-95 (200ms) |
| Overlay | fade-in-0 / fade-out-0 (200ms) |
| Overlay tint | bg-foreground/24 (Yuno dimmer, no blur) |
| Radius | rounded-lg (10px) |
| Shadow | shadow-lg (composite 2-layer) |
Recipes
Ready-to-copy compositions covering the most common Yuno usages of this atom.
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>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>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>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>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.
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.
| Prop | Type | Default | Description |
|---|---|---|---|
| Dialog.open / onOpenChange | boolean / (open: boolean) => void | — | Controlled 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.asChild | boolean | false | Merges Radix's trigger props onto the child element instead of rendering a button. Standard shadcn pattern for using your own Button. |
| DialogContent.className | string | — | Merged 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. |
| DialogHeader | component | — | The 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. |
| DialogBody | component | — | Required 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. |
| DialogFooter | component | — | Bottom 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). |
| DialogTitle | component | — | Required by Radix for accessibility. text-lg font-semibold leading-none. Keep short — the header truncates. |
| DialogDescription | component | — | Optional 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.asChild | boolean | false | Merges 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
- 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 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.