Dialog

A modal window over the page. Built on Radix Dialog for focus trap, scroll lock, and a11y; styled with one tv slots recipe and animated with interruptible enter/exit.

Every pattern below is its own block: a live preview in a device frame you can resize (mobile / tablet / desktop, or drag the handle) plus its code. Dialogs cap at md (30rem) by default, the width nearly every dialog should use. Drag any frame narrow to watch the card shrink to fit.

Installation#

# One-time setup (tokens + lib helpers)npx koalaui-cli@latest init# Add this component (its dependencies come along)npx koalaui-cli@latest add dialog

Usage#

usage.tsxTSX
import {  Dialog, DialogTrigger, DialogContent, DialogIcon, DialogHeader,  DialogFooter, DialogTitle, DialogDescription, DialogClose,} from "@/components/ui/dialog"import { UserCircle } from "@phosphor-icons/react"import { Button } from "@/components/ui/button"export function Example() {  return (    <Dialog>      <DialogTrigger asChild>        <Button>Open dialog</Button>      </DialogTrigger>      <DialogContent>        <DialogIcon><UserCircle /></DialogIcon>        <DialogHeader>          <DialogTitle>Edit profile</DialogTitle>          <DialogDescription>            Make changes to your profile here. Click save when you're done.          </DialogDescription>        </DialogHeader>        <DialogFooter>          <DialogClose asChild>            <Button variant="ghost">Cancel</Button>          </DialogClose>          <Button>Save changes</Button>        </DialogFooter>      </DialogContent>    </Dialog>  )}

Confirmation#

A destructive confirmation: no header close button (showClose={false}) forces an explicit choice, and the leading icon is tinted red.

Form#

Place labelled fields between DialogHeader and DialogFooter. Land focus on the first field with onOpenAutoFocus; inline validation appears below each field on submit.

Compact density#

density="compact" tightens padding, gaps and the title for application UI; it re-provides itself to the header, body and footer so every part stays in sync. Best driven once via DensityProvider; see Density.

Scrollable body#

For tall content (terms, changelogs, long forms) put the middle in a DialogBody. The dialog then caps itself to the viewport and only the body scrolls, its edges fading into the dialog while the header and footer stay pinned. It runs edge to edge, so the fade and the scrollbar use the full width. When it holds only text, give it tabIndex={0} so a keyboard user can scroll it.

Mobile#

Below the sm breakpoint a dialog docks to the bottom edge as a sheet: within thumb reach, with square bottom corners, rising on the drawer curve and keeping the home indicator’s safe area clear. This frame opens at phone width; switch it to Tablet or Desktop to watch the same dialog float back to the center. Pass mobile="center" to keep a dialog floating at every width. There is no grab handle, since a handle promises a drag: reach for Drawer when you want swipe to dismiss.

Nested dialogs#

Open a dialog from inside another and the one underneath steps back: it scales to 90% and lifts slightly, and a lighter second scrim keeps the page from going black. It is plain CSS on the real component, so there is nothing to wire, and closing the top dialog brings the other forward in step with its exit. It tracks two levels, which is also the most a flow should ask for.

Share#

A leading DialogIcon above composed Input parts: a read-only link with a copy button, and an email field paired with a send action.

Feedback#

A rating prompt: a single-select ToggleGroup for the score and a Textarea with a live character count. The primary action stays disabled until a score is picked, and the split footer carries a help link on the left.

Two-factor (2FA)#

A verification flow: a live QR Code on a nested surface (so it takes a concentric radius one step inside the dialog) above an OTP Input. Verify enables only once all six digits are in.

Selectable cards#

A single choice rendered as cards rather than bare dots: wrap each option in a <label> around a RadioGroup item and let CSS :has([data-state=checked]) light the selected card. The whole card is the hit target.

Announcement#

A leading DialogIcon, a hero media band, and a split footer: a helper on the left with actions on the right via className="sm:justify-between" (the footer's top divider is on by default).

Multi-step (wizard)#

For create flows that span several screens, put a vertical Stepper in a left rail and swap the active step's form on the right. The Stepper owns no state: drive its value from your own step state. Wrap the body in a Stagger keyed by step so each screen cascades in, and give it a min-h so the dialog holds its height between steps.

Upgrade / Subscribe#

An in-app upsell: a two-column dialog pairing edge-to-edge cover art with a benefits list and a single brand CTA. It composes the same dialogVariants recipe, cancelling the content padding and gap (p-0 gap-0) and splitting into two columns; use size="xl". Drag the frame narrow to watch the columns stack.

Sizes#

size on DialogContent caps the max width; the dialog still shrinks to fit narrow viewports, so it is a ceiling, not a fixed width. The default md (30rem) is the width nearly every dialog should use; reach for the others only when one genuinely needs less or more:

  • sm: max-w-sm (384px). Confirmations and short prompts.
  • md: max-w-[30rem] (480px). The default; most forms and messages.
  • lg: max-w-2xl (672px). Denser forms with side-by-side fields.
  • xl: max-w-4xl (896px). Rich content: tables, multi-column layouts.
  • media: a 16:9 frame as wide as the viewport allows while it still fits the height. A video lightbox: no padding, no header or footer, the embedded player fills it, and the close button turns light over the footage. Pair it with mobile="center" (a video is not a sheet) and give it a visually hidden DialogTitle.

The previews below are the real DialogContent rendered inline so you can compare the widths directly:

size="sm"max-w-sm · 24rem / 384pxConfirmations and short prompts.
size="md"max-w-[30rem] · 30rem / 480pxThe default; the width nearly every dialog should use.
size="lg"max-w-2xl · 42rem / 672pxDenser forms with side-by-side fields.
size="xl"max-w-4xl · 56rem / 896pxRich content: tables, multi-column layouts.
size="media"16:9, as wide as the viewport allowsA video lightbox.

Unsaved changes#

Behavior that needs a real trigger, so this one stays interactive. Use a controlled open / onOpenChange pair and intercept onInteractOutside + onEscapeKeyDown to guard against accidental data loss. When the form is dirty, every close attempt (the ✕, the scrim, Escape, or Cancel) opens a nested confirmation dialog instead.

FAQ#

Use Dialog for a focused, blocking task that needs a focus trap and scrim, like a confirmation, a short form, or terms you must accept. Reach for Drawer when the content is a side panel or a long task on mobile, and Popover for a small non-modal surface anchored to a trigger that does not lock the page.