Dialog
A modal overlay that interrupts the flow to confirm an action or collect a small amount of input. Built on base-ui Dialog — focus trapping, scroll locking, Escape-to-close, and ARIA wiring come for free.
Compose the parts: Dialog (root, owns the open state), DialogTrigger (the element that opens it), and DialogContent (the popup — it renders the backdrop, the centered panel, and a corner close button). Inside the content, lay out DialogHeader (DialogTitle + DialogDescription) and DialogFooter (the actions). DialogClose dismisses the dialog from anywhere inside it. For destructive confirmations, reach for AlertDialog instead — it forces an explicit choice and traps Escape.
Basic
A trigger plus a content panel with a title, description, and a footer. Following the VESYL dialog convention, the cancel action sits on the left and the primary (here destructive) action on the right. Wrap each part’s underlying element with render so a Button becomes the trigger and the close actions.
With a form
Dialogs are a natural home for short forms. Put the fields between the header and footer; the panel grows to fit, and DialogContent uses spacing="default" (gap-6) so sections stay evenly spaced. Use spacing="compact" (gap-4) for dense list or picker dialogs instead of overriding the panel’s gap with className.
Wider content
DialogContent caps at sm:max-w-md by default. Override the width with a className (e.g. sm:max-w-2xl) when the body is a list, table, or multi-column layout that needs the room.
Tall content — DialogBody
A dialog taller than the viewport scrolls as one piece by default, taking its
title and buttons with it. scrollable + DialogBody bounds the popup and gives
the body its own scroll region, so the header and footer stay put.
DialogBody bleeds to the popup’s edges and re-applies the horizontal padding
inside the scroll region. That is what keeps the scrollbar in the padding
gutter rather than across the right edge of a field — padding outside with the
scrollbar inside is the arrangement that overlaps content.
It scrolls natively rather than wrapping ScrollArea.
The platform scrollbar is thin and hides itself; a custom one is always visible
and heavier than a form body needs. scrollbar-gutter: stable keeps the
platforms that reserve scrollbar space from shifting content when it appears.
Edge hints are on by default here, since a bounded body gives no other sign it is
cut off — but they suppress themselves when the platform draws a permanent
scrollbar, which already carries that signal. Pass scrollHint={false} to drop
them entirely.
Both parts are needed: scrollable alone leaves the body with no height to be
constrained by, and DialogBody alone has nothing bounding it.
scrollable is opt-in because it swaps the popup to overflow-hidden, handing
the scroll to the body. A dialog that is only a header and a footer — most
confirms — has no body to take it, and a long description there would clip
instead of scroll. So it stays opt-in until the unconditional shape keeps a
fallback overflow. Reach for DialogBody in anything new either way; a
hand-rolled scrolling div is what puts a scrollbar across a field’s right edge.
A wrapping <form> needs nothing. Form.Root renders no element, so the form
is the only box standing between the panel and its parts — and it is a
submission boundary, not layout. scrollable gives it display: contents,
handing the header, body and footer straight to the panel’s column as if the
form were not there. No min-h-0 flex-1 at the call site, and no spacing classes
on the form.
Real chrome is different. A dialog that puts its body behind Tabs has boxes
that genuinely lay out — the tab list is visible — so that dialog makes the tab
panel the column itself (flex min-h-0 flex-1 flex-col) rather than the panel
guessing at markup it cannot see.
The body spaces its own children (gap-6, matching what the panel gives the
header and footer). Tighten a dense set of fields with
contentClassName="gap-4" — a space-y-* there would add margins on top of the
gap rather than replacing it.
Hiding the close button
Pass showCloseButton={false} to DialogContent to drop the corner ✕ — useful for flows that should only exit through a footer action. DialogFooter has a matching showCloseButton that appends a ready-made outline “Close” button so you don’t have to wire one up. This demo also uses spacing="compact" for the tighter panel rhythm.
Controlled
Dialog is uncontrolled by default — DialogTrigger and DialogClose toggle it for you. To drive it yourself (open it from a menu item, close it only after a mutation resolves), pass open and onOpenChange. The exported DialogProps type captures that pair for component props.
import { type DialogProps } from "@vesyl/ui-next";
function DeleteBoxDialog({ box, open, onOpenChange }: DialogProps & { box: Box }) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>{/* … */}</DialogContent>
</Dialog>
);
}Reference
Dialog
Root container; owns the open state. Extends base-ui Dialog.Root props.
| Prop | Type | Default | Description |
|---|---|---|---|
defaultOpen | boolean | false | Initial open state (uncontrolled) |
open | boolean | — | Open state (controlled) |
onOpenChange | (open: boolean) => void | — | Fires when the open state should change |
modal | boolean | true | Trap focus and block interaction behind it |
DialogTrigger
The element that opens the dialog. Extends base-ui Dialog.Trigger props.
| Prop | Type | Default | Description |
|---|---|---|---|
render | React.ReactElement | — | Render as another element, e.g. render={<Button />} |
DialogContent
The popup. Renders the backdrop, the centered panel, and (by default) a corner close button. Extends base-ui Dialog.Popup props.
| Prop | Type | Default | Description |
|---|---|---|---|
showCloseButton | boolean | true | Render the ✕ button in the top-right corner |
showBackdrop | boolean | true | Render the dimming overlay; set false for dialogs nested in a Sheet |
spacing | "default" | "compact" | "default" | Direct-child gap (gap-6 or gap-4) |
className | string | — | Merged onto the panel — override the sm:max-w-md width cap here |
DialogHeader
Stacks the title and description with a small gap. Plain div.
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | — | Merged onto the div |
DialogTitle
The accessible heading, wired to the dialog via ARIA. Extends base-ui Dialog.Title props.
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | — | Merged onto the title |
DialogDescription
Supporting copy under the title, wired to the dialog via ARIA. Links inside it are auto-underlined. Extends base-ui Dialog.Description props.
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | — | Merged onto the description |
DialogFooter
Lays out actions — stacked on mobile, right-aligned in a row on sm+. Plain div.
| Prop | Type | Default | Description |
|---|---|---|---|
showCloseButton | boolean | false | Append a ready-made outline “Close” button that dismisses |
className | string | — | Merged onto the div |
DialogClose
Dismisses the dialog when activated. Extends base-ui Dialog.Close props.
| Prop | Type | Default | Description |
|---|---|---|---|
render | React.ReactElement | — | Render as another element, e.g. render={<Button />} |
DialogProps
Exported helper type for controlled-dialog component props.
| Field | Type | Description |
|---|---|---|
open | boolean | Controlled open state |
onOpenChange | (open: boolean) => void | Called when the open state should change |