Dialog

Modal dialog built from scratch — focus trap, escape-to-close, scroll lock, backdrop blur, scale-in (centred) or slide-down (top) animation. Five width presets, two positions, structured Header + Footer slots, and explicit controls for overlay-click and escape dismissal.

Anatomy

Dialogs are composed from a small set of subcomponents:

import {
  DialogClose,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogRoot,
  DialogTitle,
  DialogTrigger,
} from '@bwo-ui/react';

<DialogRoot>
  <DialogTrigger asChild>
    <Button>Move to trash</Button>
  </DialogTrigger>
  <DialogContent>
    <DialogHeader>
      <DialogTitle>Move 3 items to trash</DialogTitle>
      <DialogDescription>
        They'll stay in trash for 30 days before being permanently deleted.
      </DialogDescription>
    </DialogHeader>

    <p>What's included:</p>
    <ul>{/* file list */}</ul>

    <DialogFooter>
      <DialogClose asChild>
        <Button variant="ghost">Cancel</Button>
      </DialogClose>
      <DialogClose asChild>
        <Button variant="solid">Move to trash</Button>
      </DialogClose>
    </DialogFooter>
  </DialogContent>
</DialogRoot>

Sizes

Five width presets cover the common cases. md (480 px) is the default — sized for confirmations and short forms. lg (640 px) and xl (800 px) for forms with more fields or stacked sections. full (92 vw, capped at 1100 px) for image previews and dashboards.

<DialogContent size="sm">…</DialogContent>     {/* 360 px */}
<DialogContent size="md">…</DialogContent>     {/* 480 px (default) */}
<DialogContent size="lg">…</DialogContent>     {/* 640 px */}
<DialogContent size="xl">…</DialogContent>     {/* 800 px */}
<DialogContent size="full">…</DialogContent>   {/* 92 vw */}

Position

position="center" (default) vertically centres the dialog with a scale-in animation — the right choice for short confirmations. position="top" pins 12 vh from the top with a slide-down animation — better for taller content (forms with multiple fields, command palettes, long lists) where centring would push content out of view.

<DialogContent>…</DialogContent>                {/* center, default */}
<DialogContent position="top" size="lg">…</DialogContent>

Form modal

A common pattern: a lg top-positioned dialog with a form inside. The focus trap keeps tab navigation within the dialog; the submit handler closes the dialog on success (or swaps content to a success state, as in this demo).

<DialogRoot onOpenChange={(open) => { if (open) setSubmitted(false); }}>
  <DialogTrigger asChild>
    <Button>Invite a teammate</Button>
  </DialogTrigger>
  <DialogContent size="lg" position="top">
    <DialogHeader>
      <DialogTitle>Invite a teammate</DialogTitle>
      <DialogDescription>They'll get an email with a link to join.</DialogDescription>
    </DialogHeader>
    <form onSubmit={handleSubmit}>
      <input type="email" className="bwo-input" required />

      <SelectRoot value={role} onValueChange={setRole}>
        <SelectTrigger>
          <SelectValue placeholder="Pick a role…" />
        </SelectTrigger>
        <SelectContent>
          <SelectItem value="viewer">Viewer</SelectItem>
          <SelectItem value="editor">Editor</SelectItem>
          <SelectItem value="admin">Admin</SelectItem>
        </SelectContent>
      </SelectRoot>

      <DialogFooter>
        <DialogClose asChild>
          <Button variant="ghost" type="button">Cancel</Button>
        </DialogClose>
        <Button type="submit">Send invite</Button>
      </DialogFooter>
    </form>
  </DialogContent>
</DialogRoot>

Image preview

For media-only modals (image lightboxes, video players, embedded canvases), combine size="full" with unpadded. The content fills the dialog edge-to-edge and the close button switches to a dark, overlay-friendly treatment.

<DialogContent size="full" unpadded>
  <img src="/hero.jpg" alt="Hero composition"
       style={{ width: '100%', display: 'block', borderRadius: 'var(--bwo-radius-md)' }} />
</DialogContent>

Controlled

Pass open + onOpenChange to own the state externally — useful when the dialog is triggered by something other than its own trigger (a deep link, a keyboard shortcut, a parent component's effect).

State: closed
const [open, setOpen] = useState(false);

<DialogRoot open={open} onOpenChange={setOpen}>
  <DialogContent>…</DialogContent>
</DialogRoot>

{/* Open from anywhere */}
<Button onClick={() => setOpen(true)}>Open via state</Button>

Escape hatches

By default a dialog dismisses on overlay click and Escape. For destructive or in-progress actions, lock both off so the user has to click an explicit button.

{/* Standard dismiss (default) */}
<DialogContent>…</DialogContent>

{/* Confirm-only — overlay + Escape disabled */}
<DialogContent closeOnOverlayClick={false} closeOnEscape={false}>
  <DialogHeader>
    <DialogTitle>Delete workspace</DialogTitle>
    <DialogDescription>This cannot be undone.</DialogDescription>
  </DialogHeader>
  <DialogFooter>
    <DialogClose asChild><Button variant="ghost">Cancel</Button></DialogClose>
    <DialogClose asChild><Button variant="solid">Delete</Button></DialogClose>
  </DialogFooter>
</DialogContent>

Accessibility

DialogContent props

PropTypeDefaultDescription
size'sm' | 'md' | 'lg' | 'xl' | 'full''md'360 / 480 / 640 / 800 px or 92 vw for `full` (capped at 1100 px).
position'center' | 'top''center''center' vertically centres with a scale-in animation. 'top' pins 12 vh from the top with slide-down.
unpaddedbooleanRemove the inner padding — for image previews / video where the body manages its own layout.
closeOnOverlayClickbooleantrueWhether clicking the overlay dismisses the dialog.
closeOnEscapebooleantrueWhether pressing Escape dismisses the dialog.
hideOverlaybooleanSkip the built-in backdrop. Use for fully custom overlays (e.g. solid colour + custom blur).
hideClosebooleanSkip the built-in × button — useful when your footer has explicit dismiss buttons only.
aria-labelstringFallback accessible name when you don't render a `DialogTitle`.

DialogRoot props

PropTypeDefaultDescription
openbooleanControlled open state. Pair with `onOpenChange`.
defaultOpenbooleanfalseUncontrolled initial state.
onOpenChange(open: boolean) => voidFired whenever the dialog opens or closes (controlled and uncontrolled).
modalbooleantrueWhen `false`, omit the focus trap and scroll lock. The dialog still portals — but users can interact with the page behind it.

Subcomponents

PropTypeDefaultDescription
DialogTrigger<button>Opens the dialog. Use `asChild` to clone props onto your own component (typically a `Button`).
DialogHeader<div>Wrapper for `DialogTitle` + `DialogDescription`. Adds a 18 px bottom margin.
DialogTitle<h2>Wires up `aria-labelledby` on the content automatically.
DialogDescription<p>Wires up `aria-describedby` on the content automatically.
DialogFooter<div>Right-aligned flex row for action buttons. Adds a 1 px top divider and 22 px top margin.
DialogClose<button>Dismisses the dialog when clicked. Use `asChild` to wrap a real Button. Note: the built-in × button in the top-right also dismisses.
DialogOverlay<div>Optional — the overlay is rendered automatically by `DialogContent`. Use this only if you've passed `hideOverlay` and want full control.