Dialog & Sheet

Free · MIT

Modal 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.

MarkdownOpen in v0
Open in v0

Installation

$ npx shadcn@latest add https://goodui.dev/r/dialog.json

The 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

Use it for
  • 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.
Reach for something else
  • 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 needUse
Confirm a destructive actionrole="alertdialog" tone="danger" with onConfirm (+ confirmPhrase for irreversible ones)
A formplacement="center" (default) with children; mobile="fullscreen" for long forms on phones
Details or filters beside the pageplacement="end" (or "start"); they follow the reading direction
Phonesmobile="sheet" turns a center dialog into a draggable bottom sheet under 640px
Command palette or searchplacement="top" size="lg"
Delight on a marketing pageanimation="morph" (grows out of the trigger) or "blur"
On photos or colorful backgroundsvariant="glass" with overlay="blur"
Playful or retro brandsvariant="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

PropTypeDefault
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.
booleanfalse
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.
booleantrue
closeOnOutsideClick
Click on the backdrop closes. Default: true for dialog, false for alertdialog.
boolean—
dragToClose
Bottom sheet: drag the handle down to close.
booleantrue
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.
booleantrue
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.
booleantrue
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 / SpaceOn the trigger: opens the dialog (native button).
Tab / Shift+TabMoves focus inside the dialog; at the last or first control it wraps around.
EscCloses the top dialog only. With closeOnEsc={false}, or while onConfirm is running, the panel gives a small bump instead.
EnterIn 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

Popover

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.

Tooltip

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.

Button

One button with ten variants, six tones, seven shadows, ten animations, three shapes and five sizes that all combine, plus loading and icon slots.

Alert

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.

Morphing dialog

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.

Spring drawer

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.