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
| Property | Token | Value |
|---|---|---|
| Background | bg-card | white |
| Border | border | 1px solid border color |
| Border radius | rounded-lg | — |
| Shadow | shadow-lg | Multi-layer drop shadow |
| Padding | p-6 | 24px |
| Max height | max-h-[calc(100dvh-4rem)] | Viewport height less 32px top and bottom |
Dialog Header
| Property | Token | Value |
|---|---|---|
| Gap | gap-1.5 | 6px between title and description |
| Alignment (lg) | text-left | Left-aligned |
| Alignment (sm) | text-center | Center-aligned |
Typography
| Element | Size | Color | Weight |
|---|---|---|---|
| Title | text-lg (18px) | text-foreground | semibold |
| Description | text-sm (14px) | text-muted-foreground | normal |
Dialog Footer
| Property | Desktop (lg) | Mobile (sm) |
|---|---|---|
| Direction | flex-row | flex-col |
| Gap | gap-2 (8px) | gap-2 (8px) |
| Alignment | justify-end | items-start (full width) |
| Button width | Auto | Full width (w-full) |
| Button order | Cancel → Primary | Primary → Cancel |
Close Icon
| Property | Token | Value |
|---|---|---|
| Size | size-4 | 16px × 16px |
| Position | right-4 top-4 | 15px from edges |
| Border radius | rounded-xs | 2px |
| Default opacity | opacity-70 | 70% |
| Color | inherited | Inherits from parent foreground |
Buttons
| Property | Token | Value |
|---|---|---|
| Height | h-9 | 36px |
| Padding | px-4 py-2 | 16px horizontal, 8px vertical |
| Border radius | rounded-md | 8px |
| Shadow | shadow-xs | Subtle 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
| Breakpoint | Use For | Layout |
|---|---|---|
lg | Desktop | Side-by-side footer buttons, left-aligned header |
sm | Mobile | Stacked 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
| Type | Use For |
|---|---|
| Form | Collecting user input (name, email, settings) |
| Text | Displaying information, confirmations, long content |
States
| State | Implementation |
|---|---|
| Open | Controlled via open prop or DialogTrigger |
| Closed | onOpenChange(false) or close button click |
| Overlay | bg-foreground/50 backdrop, closes on click |
Close Icon States
| State | Implementation |
|---|---|
| Default | opacity-70 |
| Hover | opacity-100 |
| Focus | ring-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 underprefers-reduced-motion: reduce. Gate every animation utility, not justanimate-in/animate-out: a partly-gated exit animation can leave Radix'spointer-events: noneon<body>after close, which makes the whole page unclickable - Screen reader: announces as
dialogrole witharia-modal="true" - Title/description linked via
aria-labelledby/aria-describedby
Gotchas
| Problem | Solution |
|---|---|
| Dialog won't close | Use DialogClose wrapper or control via onOpenChange |
| Trigger not working | Add asChild prop to DialogTrigger |
| Footer buttons misaligned | Use DialogFooter — handles responsive layout |
| Content overflows | Nothing 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 view | Wrap 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
- Accessibility: Dialog Accessibility Requirements - Focus trapping, keyboard behavior, ARIA
- Related Components: Alert Dialog, Sheet, Drawer
- Patterns: Component Match
Last updated: August 19, 2026