VESYL UI

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.

PropTypeDefaultDescription
defaultOpenbooleanfalseInitial open state (uncontrolled)
openbooleanOpen state (controlled)
onOpenChange(open: boolean) => voidFires when the open state should change
modalbooleantrueTrap focus and block interaction behind it

DialogTrigger

The element that opens the dialog. Extends base-ui Dialog.Trigger props.

PropTypeDefaultDescription
renderReact.ReactElementRender 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.

PropTypeDefaultDescription
showCloseButtonbooleantrueRender the ✕ button in the top-right corner
showBackdropbooleantrueRender the dimming overlay; set false for dialogs nested in a Sheet
spacing"default" | "compact""default"Direct-child gap (gap-6 or gap-4)
classNamestringMerged onto the panel — override the sm:max-w-md width cap here

DialogHeader

Stacks the title and description with a small gap. Plain div.

PropTypeDefaultDescription
classNamestringMerged onto the div

DialogTitle

The accessible heading, wired to the dialog via ARIA. Extends base-ui Dialog.Title props.

PropTypeDefaultDescription
classNamestringMerged 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.

PropTypeDefaultDescription
classNamestringMerged onto the description

DialogFooter

Lays out actions — stacked on mobile, right-aligned in a row on sm+. Plain div.

PropTypeDefaultDescription
showCloseButtonbooleanfalseAppend a ready-made outline “Close” button that dismisses
classNamestringMerged onto the div

DialogClose

Dismisses the dialog when activated. Extends base-ui Dialog.Close props.

PropTypeDefaultDescription
renderReact.ReactElementRender as another element, e.g. render={<Button />}

DialogProps

Exported helper type for controlled-dialog component props.

FieldTypeDescription
openbooleanControlled open state
onOpenChange(open: boolean) => voidCalled when the open state should change