Dialog

A window overlaid on either the primary window or another dialog window, rendering the content underneath inert.

Spec · from metadata

When to use

  • Confirming a user action that needs focused attention
  • Displaying supplementary content without navigating away
  • Short forms or configuration panels that overlay the current view

When not to use

  • Destructive action confirmation — use AlertDialog instead (it blocks background interaction and requires explicit choice)
  • Simple tooltips or popovers — use Popover or Tooltip
  • Full-page forms — use a dedicated route or Card-based layout
  • Non-blocking notifications — use Toast or Alert

Required companions

Environmental prerequisites this component needs beyond its props. Supply these or it won't work as expected — TypeScript won't catch a missing one.

CompanionKindWhy
PortalContainerProvider (existing-app only)providerDialogContent portals to <body>. In an app with its own chrome/theme, route it into a body-level .helix node at z-1300 via PortalContainerProvider, or the overlay loses Helix tokens and renders under the chrome (see helix-app-layouts AL1). Not needed in a greenfield app.

Anti-patterns

Avoid<Dialog>
  <DialogContent>
    <p>Are you sure you want to delete?</p>
    <Button variant="destructive">Delete</Button>
  </DialogContent>
</Dialog>
Prefer<AlertDialog>
  <AlertDialogContent>
    <AlertDialogHeader>
      <AlertDialogTitle>Are you sure?</AlertDialogTitle>
    </AlertDialogHeader>
    <AlertDialogFooter>
      <AlertDialogCancel>Cancel</AlertDialogCancel>
      <AlertDialogAction>Delete</AlertDialogAction>
    </AlertDialogFooter>
  </AlertDialogContent>
</AlertDialog>

Destructive confirmations MUST use AlertDialog which requires an explicit user choice and cannot be dismissed by clicking the overlay.

Avoid<DialogContent>
  <h2>Title</h2>
</DialogContent>
Prefer<DialogContent>
  <DialogHeader>
    <DialogTitle>Title</DialogTitle>
  </DialogHeader>
</DialogContent>

Radix requires DialogTitle for accessibility. Use DialogHeader + DialogTitle.

Accessibility

  • Required ARIAaria-labelledby (auto via DialogTitle)aria-describedby (auto via DialogDescription)
  • Screen readerClose button includes sr-only 'Close' text. Radix manages aria attributes automatically when DialogTitle and DialogDescription are present.

Token bindings

TokenCategoryUsage
bg-cardcolorDialog content background
bg-foreground/50colorOverlay backdrop
shadow-lgshadowDialog elevation
rounded-lgradiusContent border radius
duration-200motionOpen/close animation
Recent changes
  • new

    Dialog caps its height, and DialogBody pins the header and footer

    2026-08-19

Import

import {
  Dialog,
  DialogTrigger,
  DialogContent,
  DialogHeader,
  DialogTitle,
  DialogDescription,
  DialogBody,
  DialogFooter,
  DialogClose,
} from "@timelycare/helix-ui"

Props

interface DialogProps {
  open?: boolean
  onOpenChange?: (open: boolean) => void
  children: React.ReactNode
}

interface DialogContentProps {
  className?: string
  children: React.ReactNode
  showCloseButton?: boolean      // default true
  closeOnOutsideClick?: boolean  // default true — set false for data-entry dialogs
}

Design Tokens

Dialog Content

PropertyTokenValue
Backgroundbg-cardwhite
Borderborder1px solid border color
Border radiusrounded-lg
Shadowshadow-lgMulti-layer drop shadow
Paddingp-624px
Max heightmax-h-[calc(100dvh-4rem)]Viewport height less 32px top and bottom

Dialog Header

PropertyTokenValue
Gapgap-1.56px between title and description
Alignment (lg)text-leftLeft-aligned
Alignment (sm)text-centerCenter-aligned

Typography

ElementSizeColorWeight
Titletext-lg (18px)text-foregroundsemibold
Descriptiontext-sm (14px)text-muted-foregroundnormal

Dialog Footer

PropertyDesktop (lg)Mobile (sm)
Directionflex-rowflex-col
Gapgap-2 (8px)gap-2 (8px)
Alignmentjustify-enditems-start (full width)
Button widthAutoFull width (w-full)
Button orderCancel → PrimaryPrimary → Cancel

Close Icon

PropertyTokenValue
Sizesize-416px × 16px
Positionright-4 top-415px from edges
Border radiusrounded-xs2px
Default opacityopacity-7070%
ColorinheritedInherits from parent foreground

Buttons

PropertyTokenValue
Heighth-936px
Paddingpx-4 py-216px horizontal, 8px vertical
Border radiusrounded-md8px
Shadowshadow-xsSubtle drop shadow

Height and Scrolling

DialogContent caps its own height at calc(100dvh - 4rem) — the viewport height less 32px of breathing room top and bottom. Because the panel is vertically centred, an uncapped dialog taller than the viewport bleeds off both edges at once, putting the title and the action buttons out of reach with nothing to scroll. The cap makes that impossible, so you never need to add a height constraint of your own.

dvh rather than vh is deliberate: on mobile Safari vh resolves against the largest possible viewport, so a vh-capped dialog still clips while the URL bar is showing.

What the cap does not decide is which part scrolls once the content exceeds it. That is what DialogBody marks, and every tall dialog should mark it.

Mark the body with DialogBody

Wrap the middle in DialogBody and only that region scrolls; DialogHeader and DialogFooter stay fixed, so the action buttons are one click away at any scroll position.

<DialogContent>
  <DialogHeader>
    <DialogTitle>Edit profile</DialogTitle>
    <DialogDescription>Update your details below.</DialogDescription>
  </DialogHeader>
  <DialogBody>
    <div className="flex flex-col gap-4">{/* many fields */}</div>
  </DialogBody>
  <DialogFooter>
    <Button variant="outline">Cancel</Button>
    <Button type="submit">Save changes</Button>
  </DialogFooter>
</DialogContent>

DialogBody takes className and any other div props. It is detected by its data-slot="dialog-body" attribute, so it must be rendered inside DialogContent (a wrapper element in between is fine).

Dialogs that have not been marked yet

Omit DialogBody and DialogContent becomes the scroller itself: the content, the header and the footer all scroll as one block. Nothing is unreachable, but the title and the action buttons travel out of view with everything else — which is the defect DialogBody exists to fix.

That behaviour is kept only so dialogs written before DialogBody existed did not break the day it shipped. It is a transition state, not a second option. In development Helix logs a console notice for any dialog that is tall enough to scroll and has a header or footer but no DialogBody, so the ones still to convert name themselves instead of persisting quietly.

A worked example — long content that really overflows

DialogBody only changes anything once the content is taller than the cap, so here is the full thing with real content. This is the "Tall content" specimen in the Dialog preview: a 30-entry intake audit trail wrapped in DialogBody.

<Dialog>
  <DialogTrigger asChild>
    <Button variant="outline">View audit trail</Button>
  </DialogTrigger>
  <DialogContent>
    <DialogHeader>
      <DialogTitle>Intake audit trail</DialogTitle>
      <DialogDescription>
        Every recorded action on this member's intake, newest last. Review the
        entries before locking the record.
      </DialogDescription>
    </DialogHeader>
    <DialogBody>
      <ol className="flex flex-col">
        {auditTrail.map((entry) => (
          <li
            key={entry.id}
            className="flex flex-col gap-0.5 border-b border-border py-2 last:border-b-0"
          >
            <span className="text-sm text-foreground">{entry.event}</span>
            <span className="text-xs text-muted-foreground">
              {entry.time} · {entry.actor}
            </span>
          </li>
        ))}
      </ol>
    </DialogBody>
    <DialogFooter>
      <DialogClose asChild>
        <Button variant="outline">Cancel</Button>
      </DialogClose>
      <Button>Mark as reviewed</Button>
    </DialogFooter>
  </DialogContent>
</Dialog>

Measured on a 700px-tall viewport, the panel settles at its cap — 636px, so 100dvh - 4rem — with roughly 1,200px of scroll left inside DialogBody. Scrolling the body moves the entries and nothing else: DialogHeader and DialogFooter do not shift by a pixel, so "Mark as reviewed" is one click away at any scroll position.

Delete the <DialogBody> wrapper and everything else stays the same, but the panel itself becomes the scroller. At the same 636px cap the footer now sits about 1,800px down the scroll container — off-screen on open — and the title scrolls away as soon as you move. Nothing is unreachable, so an un-marked dialog is not broken; it just puts a committing action somewhere the user has to go looking for it. Mark the body.


Breakpoints

BreakpointUse ForLayout
lgDesktopSide-by-side footer buttons, left-aligned header
smMobileStacked footer buttons (full-width), centered header

Responsive Behavior

  • On mobile (sm), buttons stack vertically with primary action first, header text centers
  • On desktop (lg), buttons align horizontally in footer with cancel first, header left-aligned

Content Types

TypeUse For
FormCollecting user input (name, email, settings)
TextDisplaying information, confirmations, long content

States

StateImplementation
OpenControlled via open prop or DialogTrigger
ClosedonOpenChange(false) or close button click
Overlaybg-foreground/50 backdrop, closes on click

Close Icon States

StateImplementation
Defaultopacity-70
Hoveropacity-100
Focusring-2 ring-ring ring-offset-2

Common Patterns

Form Dialog

<Dialog>
  <DialogTrigger asChild>
    <Button>Edit Profile</Button>
  </DialogTrigger>
  <DialogContent>
    <DialogHeader>
      <DialogTitle>Dialog title</DialogTitle>
      <DialogDescription>This is a dialog description.</DialogDescription>
    </DialogHeader>
    <div className="space-y-4 py-4">
      <Input placeholder="Name" />
      <Input placeholder="Username" />
    </div>
    <DialogFooter>
      <DialogClose asChild>
        <Button variant="outline">Cancel</Button>
      </DialogClose>
      <Button>Save changes</Button>
    </DialogFooter>
  </DialogContent>
</Dialog>

Text/Confirmation Dialog

<DialogContent>
  <DialogHeader>
    <DialogTitle>Dialog title</DialogTitle>
    <DialogDescription>This is a dialog description.</DialogDescription>
  </DialogHeader>
  <div className="text-sm text-muted-foreground">
    <p>Lorem ipsum dolor sit amet...</p>
  </div>
  <DialogFooter>
    <DialogClose asChild>
      <Button variant="outline">Cancel</Button>
    </DialogClose>
  </DialogFooter>
</DialogContent>

Success Confirmation Dialog

<Dialog open={showSuccess} onOpenChange={setShowSuccess}>
  <DialogContent className="sm:max-w-[400px]">
    <div className="flex flex-col items-center text-center py-4">
      <div className="size-16 rounded-full bg-accent flex items-center justify-center mb-4">
        <CircleCheck className="size-8 text-accent-foreground" />
      </div>
      <DialogTitle>Payment Successful</DialogTitle>
      <DialogDescription className="mt-2">
        Your payment of $99.00 has been processed.
      </DialogDescription>
    </div>
    <DialogFooter>
      <Button className="w-full" onClick={() => setShowSuccess(false)}>
        Continue
      </Button>
    </DialogFooter>
  </DialogContent>
</Dialog>

Controlled Dialog

const [open, setOpen] = useState(false)

<Dialog open={open} onOpenChange={setOpen}>
  <DialogContent>/* ... */</DialogContent>
</Dialog>

Accessibility

  • Built on Radix Dialog — handles focus trap and keyboard automatically
  • Focus trapped within dialog when open; on close it returns to whatever had focus before the dialog opened. That is normally the trigger, and it also covers dialogs opened programmatically and triggers that unmount while the dialog is open, where focus would otherwise fall back to <body> and a keyboard user would restart from the top of the page
  • Keyboard: Escape to close, Tab/Shift+Tab to cycle focus
  • closeOnOutsideClick={false} stops a stray backdrop click from discarding a part-filled form. Escape still closes the dialog either way, so it never becomes a keyboard trap
  • Open and close animations are gated behind motion-safe:, so they are skipped entirely under prefers-reduced-motion: reduce. Gate every animation utility, not just animate-in/animate-out: a partly-gated exit animation can leave Radix's pointer-events: none on <body> after close, which makes the whole page unclickable
  • Screen reader: announces as dialog role with aria-modal="true"
  • Title/description linked via aria-labelledby/aria-describedby

Gotchas

ProblemSolution
Dialog won't closeUse DialogClose wrapper or control via onOpenChange
Trigger not workingAdd asChild prop to DialogTrigger
Footer buttons misalignedUse DialogFooter — handles responsive layout
Content overflowsNothing to do — DialogContent caps its height and scrolls. Wrap the middle in DialogBody to pin the header and footer instead
Action buttons scroll out of viewWrap the body content in DialogBody, which keeps DialogFooter pinned
Added your own max-h-[80vh]Remove it. The built-in cap already handles this, and vh under-reports on mobile Safari where dvh does not

See Also


Last updated: August 19, 2026