Dialog & Sheet
Free · MITModal dialog, alert dialog and sheet in one: default, soft, glass and neo surfaces in six tones; center, top, start/end/left/right and draggable bottom sheets, fullscreen, and a phone override to fullscreen or sheet; scale, slide, blur, fade or morph-from-trigger entrances; async confirm with spinner and inline error, type-to-confirm, nested dialogs that step back, focus trap and restore, and scroll lock without layout shift.
Installation
$ npx shadcn@latest add https://goodui.dev/r/dialog.jsonThe file lands in components/goodui/dialog.tsx and is yours to edit. With the @goodui registry added, npx shadcn@latest add @goodui/dialog works too.
Usage
import { Trash2 } from "lucide-react"
import { Button } from "@/components/goodui/button"
import { Dialog } from "@/components/goodui/dialog"
export function DeleteClient({ onDelete }: { onDelete: () => Promise<void> }) {
return (
<Dialog
role="alertdialog"
tone="danger"
icon={<Trash2 />}
title="Delete client?"
description="Omar Haddad and his 14 past bookings will be removed. This can't be undone."
confirmText="Delete client"
onConfirm={onDelete}
trigger={
<Button variant="soft" tone="danger">
Delete client
</Button>
}
/>
)
}When to use
- A task that needs the user's full attention before going back to the page: edit an appointment, invite a teammate, pay an invoice.
- Confirming a destructive or costly action: role="alertdialog" with onConfirm, and confirmPhrase for things like deleting a workspace.
- Side panels for details and filters (placement="end" or "start") and mobile bottom sheets (placement="bottom", or mobile="sheet" on a center dialog).
- Actions that call a server: return a promise from onConfirm and the dialog shows a spinner, waits, and keeps the error on screen if it throws.
- Small anchored content the user can ignore (a date filter, a share menu): use popover; it doesn't block the page.
- A short hint on hover or focus: use tooltip.
- Feedback that needs no answer ("Saved"): use a toast or alert; a modal interrupts for nothing.
- Long multi-page flows: give them their own route; a dialog is for one focused task.
| If you need | Use |
|---|---|
| Confirm a destructive action | role="alertdialog" tone="danger" with onConfirm (+ confirmPhrase for irreversible ones) |
| A form | placement="center" (default) with children; mobile="fullscreen" for long forms on phones |
| Details or filters beside the page | placement="end" (or "start"); they follow the reading direction |
| Phones | mobile="sheet" turns a center dialog into a draggable bottom sheet under 640px |
| Command palette or search | placement="top" size="lg" |
| Delight on a marketing page | animation="morph" (grows out of the trigger) or "blur" |
| On photos or colorful backgrounds | variant="glass" with overlay="blur" |
| Playful or retro brands | variant="neo" |
Examples
Form dialog with a custom footer and close()
<Dialog
title="Edit appointment"
description="Changes are texted to the client."
mobile="fullscreen"
trigger={<Button variant="outline">Edit</Button>}
footer={({ close }) => (
<>
<Button variant="ghost" tone="neutral" onClick={close}>Cancel</Button>
<Button onClick={async () => { await save(); close() }}>Save</Button>
</>
)}
>
<AppointmentForm />
</Dialog>Side sheet
<Dialog placement="end" size="md" title="Invite to your team" trigger={<Button>Invite</Button>}>
{({ close }) => <InviteForm onSent={close} />}
</Dialog>Type to confirm, with an error from the server
<Dialog
role="alertdialog"
tone="danger"
title="Cancel your subscription?"
confirmPhrase="CANCEL"
confirmText="Cancel subscription"
onConfirm={async () => {
const res = await fetch("/api/subscription", { method: "DELETE" })
if (!res.ok) throw new Error("Couldn't reach billing. Nothing was changed.")
}}
trigger={<Button variant="ghost" tone="danger">Cancel plan</Button>}
/>Controlled, opened from code
const [open, setOpen] = React.useState(false)
<Dialog open={open} onOpenChange={setOpen} placement="bottom" title="Pick a time">
{({ close }) => <Slots onPick={(t) => { book(t); close() }} />}
</Dialog>Nested dialog
<Dialog title="Edit appointment" trigger={<Button>Edit</Button>}>
<Dialog size="sm" title="Choose a stylist" trigger={<Button size="xs" variant="soft">Change</Button>}>
{({ close }) => <StylistList onPick={close} />}
</Dialog>
</Dialog>API reference
| Prop | Type | Default |
|---|---|---|
| trigger Element that opens the dialog (a button). It gets aria-haspopup, aria-expanded and aria-controls, and morph grows out of it. | React.ReactElement<TriggerProps> | — |
| open Open state (controlled). | boolean | — |
| defaultOpen Initial open state when uncontrolled. | boolean | false |
| onOpenChange | (open: boolean) => void | — |
| role alertdialog: for confirmations that interrupt. Outside click does not close it, there is no × by default, and focus starts on Cancel. | "dialog" | "alertdialog" | "dialog" |
| title Heading; names the dialog for screen readers. Without it, pass aria-label. | React.ReactNode | — |
| description Muted text under the title; describes the dialog for screen readers. | React.ReactNode | — |
| icon Icon in a tone-tinted tile above or beside the title. | React.ReactNode | — |
| children Body. A function receives { close }. | React.ReactNode | ((api: DialogApi) => React.ReactNode) | — |
| footer Custom footer; replaces the built-in Cancel / Confirm buttons. A function receives { close }. | React.ReactNode | ((api: DialogApi) => React.ReactNode) | — |
| variant Surface: default, soft (tone tint), glass (frosted) or neo (thick border, hard shadow). | "default" | "soft" | "glass" | "neo" | "default" |
| tone Accent for the icon tile, confirm button, soft tint and focus ring. | "primary" | "neutral" | "success" | "warning" | "danger" | "info" | "primary" |
| placement Where it appears. Sheets slide from their edge with any animation except fade, blur and none. | "center" | "top" | "start" | "end" | "left" | "right" | "bottom" | "fullscreen" | "center" |
| mobile Below 640px, a center or top dialog can become fullscreen or a bottom sheet. | "same" | "fullscreen" | "sheet" | "same" |
| size Width for dialogs and side sheets, max width for the bottom sheet. full fills the screen. | "sm" | "md" | "lg" | "xl" | "full" | "md" |
| radius | "none" | "sm" | "md" | "lg" | "xl" | "lg" |
| shadow Drop shadow. Ignored by neo, which has its own offset shadow. | "none" | "sm" | "md" | "lg" | "xl" | "lg" |
| animation How it enters and leaves. morph grows out of the trigger (falls back to scale without one). Reduced motion uses a short fade. | "none" | "fade" | "scale" | "slide" | "morph" | "blur" | "scale" |
| overlay Backdrop: dim (default), blur (frosted page) or none (transparent, still blocks the page). | "dim" | "blur" | "none" | "dim" |
| showClose × button in the corner. Default: true for dialog, false for alertdialog. | boolean | — |
| closeOnEsc Esc closes. When false (or while confirming), Esc gives a small bump instead. | boolean | true |
| closeOnOutsideClick Click on the backdrop closes. Default: true for dialog, false for alertdialog. | boolean | — |
| dragToClose Bottom sheet: drag the handle down to close. | boolean | true |
| onConfirm Shows Cancel and Confirm. May return a promise: the button shows a spinner and the dialog waits; a throw keeps it open and shows the error. | () => unknown | — |
| confirmText Confirm button text (default labels.confirm). | React.ReactNode | — |
| cancelText Cancel button text (default labels.cancel). | React.ReactNode | — |
| confirmTone Confirm button tone; defaults to tone. | "primary" | "neutral" | "success" | "warning" | "danger" | "info" | — |
| closeOnConfirm Close after onConfirm succeeds. | boolean | true |
| confirmPhrase The user must type this exact text before Confirm is enabled (deleting a workspace). | string | — |
| initialFocus Element to focus on open. Default: an autoFocus element, Cancel for alertdialog, the first field or button, else the panel. | React.RefObject<HTMLElement | null> | — |
| returnFocus Return focus to the element that had it before opening. | boolean | true |
| sound Plays "open" and "close" when the visitor has sounds on (lib/goodui-sounds); a sound name replaces the open sound. Off by default. | boolean | SoundName | — |
| labels Text for built-in controls; pass translations here. | Partial<DialogLabels> | — |
Also accepts every <div> attribute.
Accessibility
| Enter / Space | On the trigger: opens the dialog (native button). |
| Tab / Shift+Tab | Moves focus inside the dialog; at the last or first control it wraps around. |
| Esc | Closes the top dialog only. With closeOnEsc={false}, or while onConfirm is running, the panel gives a small bump instead. |
| Enter | In the type-to-confirm field: confirms once the phrase matches. |
- The panel has role="dialog" (or "alertdialog") and aria-modal="true", is named by the title (aria-labelledby) and described by the description (aria-describedby). Without a title, pass aria-label.
- The trigger gets aria-haspopup="dialog", aria-expanded and aria-controls.
- Focus moves in on open: initialFocus, else an autoFocus or data-autofocus element, else the type-to-confirm field, else Cancel for alertdialog (the least destructive choice), else the first control in the body, else the panel. It returns to the element that had it before opening.
- Tab and Shift+Tab are trapped inside the panel. The page behind is not made inert; aria-modal tells assistive tech to stay in the dialog, and the backdrop blocks pointer input.
- While onConfirm runs, the panel has aria-busy and the confirm button is disabled with a spinner. A thrown error is shown in a role="alert" message.
- The × button is a real button named by labels.close; the drag handle of the bottom sheet is decorative (Esc and the backdrop still close it).
- Every built-in string (Close, Cancel, Confirm, the type-to-confirm label, the generic error) comes from labels, so it can be translated. start/end sheets and the panel follow the reading direction.
Motion
- Enter (Web Animations, easing cubic-bezier(.16,1,.3,1)): scale from 0.94 in 260 ms, slide up 24px in 300 ms, blur from 14px in 320 ms, fade in 180 ms, morph from the trigger's box in 420 ms. Sheets slide in from their edge in 380 ms.
- Exit plays the entrance backwards in 60% of the time. The backdrop fades in over 220 ms and out with the panel. Morph shrinks back into the trigger.
- Nested: the dialog behind steps back (scale 0.94, up 14px) with a 320 ms transition. A refused close (alertdialog backdrop, Esc while confirming) bumps the panel to 1.025 for 240 ms.
- Bottom sheets follow the finger when dragged by the handle and close when let go past 35% of their height or 140px, whichever is less, or on a quick downward flick.
- Reduced motion: every entrance becomes a 140 ms fade (100 ms out), the dialog behind steps back without a transition, and there is no bump. animation="none" adds no animation at all.
- Nothing is rendered until the dialog opens; the portal unmounts after the exit animation.
- Animations run on transform and opacity through the Web Animations API. blur also animates filter, and glass uses backdrop-filter; both cost more on low-end phones.
- Scroll lock is shared: any number of open dialogs set overflow hidden on <html> once and restore it when the last one closes. scrollbar-gutter: stable (or padding where unsupported) keeps the page from shifting.
Notes for AI
Coding agents read these rules through MCP and llms.txt, or paste this page with Copy page.
- Pass the opener as trigger={<Button>…</Button>}; do not wrap Dialog in your own click handler. For dialogs opened from code use open + onOpenChange.
- For confirmations pass onConfirm (sync or async) instead of building Cancel/Confirm buttons; throw an Error to keep the dialog open and show its message.
- Use role="alertdialog" for destructive confirmations; it turns off outside-click close and the × button by default.
- children and footer can be functions receiving { close }; use them to close after an action inside the dialog.
- Sheets are placement="start" | "end" | "left" | "right" | "bottom"; prefer start/end so RTL works.
- Always give a title (or aria-label). Do not add role or aria-modal yourself.
- Do not nest a Dialog's trigger inside another interactive element.
FAQ
Can I open it without a trigger?
Yes. Leave out trigger and control it with open and onOpenChange. morph then grows out of whatever had focus when it opened.
How do I stop it from closing?
closeOnEsc={false} and closeOnOutsideClick={false}. While an onConfirm promise is pending, Esc and the backdrop are ignored automatically.
Does it work inside a scrolling container or a transformed parent?
Yes. It renders into document.body through a portal, so overflow and transforms of parents don't clip it.
Why does my page not jump when the dialog opens?
The scroll lock keeps a stable scrollbar gutter (or adds padding equal to the scrollbar width in browsers without scrollbar-gutter).
Related
Anchored popover with an arrow in default, soft, glass, neo and inverted surfaces, tones, sizes, radii and shadows; side and align that flip and shift to stay on screen, match-trigger-width, scale, fade, slide or spring entrances, header, close button and footer slots, and modal (focus trapped) or non-modal (Tab flows back to the page) behavior, shown as a date filter, share menu, switcher and inline note form.
Dark, light, tone, glass and neo tooltips with sizes, shapes, shadows and an optional arrow; twelve placements that flip and shift at the screen edge, scale, fade, slide or spring entrances, rich title + keyboard-shortcut content, delay groups that open instantly after the first, follow-cursor for charts and touch long-press.
One button with ten variants, six tones, seven shadows, ten animations, three shapes and five sizes that all combine, plus loading and icon slots.
Soft, solid, outline, left-accent, glass, neo and full-width banner alerts with tone-picked icons, actions, slide, fade, shake or pop entrances, smooth collapse on dismiss and a pausable auto-dismiss countdown.
A service card expands into a booking dialog, with its image, title and price flying into place through a shared-layout spring, then folds back into the card on Esc or backdrop click.
A bottom sheet with half and full snap points plus side drawers on a spring; drag the handle and the release speed picks the snap point or closes it, while the backdrop fades with the drag.