[!CAUTION] BASED ON STANDARD RADIX/SHADCN PATTERNS — VERIFY AGAINST YOUR IMPLEMENTATION These accessibility patterns are derived from Radix UI primitive documentation and WAI-ARIA best practices. Your actual component implementations may differ. Test with a screen reader before relying on this guidance.
Applies To
- Dialog — general-purpose modal overlay (Radix Dialog primitive)
- AlertDialog — confirmation/destructive-action modal (Radix AlertDialog primitive)
- Sheet — side panel overlay (built on Radix Dialog primitive)
- Drawer — bottom/side drawer (built on Vaul, wraps Radix Dialog patterns)
Shared Modal Accessibility Fundamentals
All four overlay components share these core accessibility requirements:
Focus Management
| Behavior | Requirement |
|---|---|
| Focus on open | Focus must move into the overlay when it opens |
| Focus trap | Focus must be trapped inside the overlay while it's open — Tab/Shift+Tab cannot leave |
| Focus on close | Focus must return to whatever held focus before the overlay opened — usually, but not always, the trigger |
Radix Dialog handles the first two automatically, and covers the third for the common case by
restoring focus to the trigger. Two cases it does not cover: a dialog opened programmatically, where
there is no trigger, and a trigger that unmounts while the dialog is open. In both, focus falls back
to <body> and a keyboard user restarts from the top of the page. Helix's DialogContent closes
that gap by recording document.activeElement on open and restoring it on close, which is why the
requirement above is written as "whatever held focus" rather than "the trigger".
ARIA Attributes (All Overlays)
| Attribute | Value | Purpose |
|---|---|---|
role | "dialog" or "alertdialog" | Identifies the overlay to assistive technology |
aria-modal | "true" | Tells screen readers that content behind the overlay is inert |
aria-labelledby | ID of the title element | Screen reader announces the title when the overlay opens |
aria-describedby | ID of the description element (optional) | Screen reader announces the description after the title |
Keyboard Interactions (All Overlays)
| Key | Action |
|---|---|
| Tab | Move focus to next focusable element inside the overlay |
| Shift+Tab | Move focus to previous focusable element inside the overlay |
| Escape | Close the overlay (except AlertDialog — see below) |
| Enter | Activate the focused button/control |
Dialog
The standard modal dialog for forms, content, and general-purpose interactions.
Role & ARIA
<Dialog>
<DialogTrigger asChild>
<Button>Edit Profile</Button>
</DialogTrigger>
<DialogContent>
{/* Radix renders: role="dialog" aria-modal="true" aria-labelledby aria-describedby */}
<DialogHeader>
<DialogTitle>Edit Profile</DialogTitle> {/* auto-linked via aria-labelledby */}
<DialogDescription> {/* auto-linked via aria-describedby */}
Make changes to your profile information.
</DialogDescription>
</DialogHeader>
{/* ... content ... */}
<DialogFooter>
<Button type="submit">Save changes</Button>
</DialogFooter>
</DialogContent>
</Dialog>
Radix Dialog automatically applies:
role="dialog"on the content elementaria-modal="true"aria-labelledbypointing toDialogTitlearia-describedbypointing toDialogDescription
Focus Behavior
| Event | Focus Target |
|---|---|
| Opens | First focusable element inside the dialog. To customize, use Radix's onOpenAutoFocus to move focus to a specific element (e.g., an input field). |
| Tab at last element | Wraps to first focusable element (focus trap) |
| Shift+Tab at first element | Wraps to last focusable element (focus trap) |
| Closes | Returns to whatever held focus before the dialog opened. Radix restores focus to the DialogTrigger; DialogContent also records document.activeElement on open, so a dialog opened programmatically (no trigger) or one whose trigger unmounted while open still restores focus instead of dropping it to <body>. To customize, use onCloseAutoFocus — calling preventDefault() on it skips the restore entirely. |
Close Behavior
- Escape closes the dialog — always, regardless of
closeOnOutsideClick - Click on overlay backdrop closes the dialog by default. Pass
closeOnOutsideClick={false}to opt out — see below - Close button (X) closes the dialog — this button must have
aria-label="Close"(or visible "Close" text)
closeOnOutsideClick
| Value | Behavior | Use for |
|---|---|---|
true (default) | Backdrop click dismisses | Read-only content, detail views, anything with nothing to lose |
false | Backdrop click is ignored | Data-entry dialogs — a stray click would otherwise discard a part-filled form with no confirmation |
Escape still closes either way, so opting out never turns the dialog into a keyboard trap. Do not
reach for onInteractOutside directly — closeOnOutsideClick is the supported control.
Reduced Motion
The overlay and content open/close animations are skipped when the user sets
prefers-reduced-motion: reduce. The dialog still opens and closes; only the transition is dropped.
This is handled once for every overlay by a rule in styles.css rather than per component —
Dialog also carries motion-safe: prefixes on its own animation classes, which are now redundant
but harmless. Nothing to add when building a new overlay. See
motion tokens for why the rule is scoped to enter/exit rather
than to *, and why it needs !important.
Screen Reader Announcements
| Event | What's Announced |
|---|---|
| Open | "Edit Profile, dialog" (role + title via aria-labelledby) then description if aria-describedby present |
| Close | Focus returns to trigger — screen reader announces the trigger element |
AlertDialog
A modal for confirmation and destructive actions. More restrictive than Dialog — the user must make an explicit choice.
Role & ARIA
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive">Delete Account</Button>
</AlertDialogTrigger>
<AlertDialogContent>
{/* Radix renders: role="alertdialog" aria-modal="true" aria-labelledby aria-describedby */}
<AlertDialogHeader>
<AlertDialogTitle>Are you sure?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete your account.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction>Yes, delete account</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
Key Differences from Dialog
| Behavior | Dialog | AlertDialog |
|---|---|---|
role | "dialog" | "alertdialog" |
| Escape to close | Yes | No — user must click Cancel or Confirm |
| Click backdrop to close | Yes by default; configurable via closeOnOutsideClick | No — overlay click does not dismiss, and this is not configurable |
| Initial focus | First focusable element | Cancel button (the safe/non-destructive action) |
Focus Behavior
| Event | Focus Target |
|---|---|
| Opens | The AlertDialogCancel button (safe action) — Radix does this automatically |
| Closes | Returns to AlertDialogTrigger |
Keyboard Interactions
| Key | Action |
|---|---|
| Tab | Cycle between Cancel and Action buttons |
| Shift+Tab | Reverse cycle |
| Enter | Activate focused button |
| Space | Activate focused button |
| Escape | Does nothing — this is intentional for AlertDialog |
Screen Reader Announcements
| Event | What's Announced |
|---|---|
| Open | "Are you sure?, alertdialog" + description. The alertdialog role signals a more urgent context to screen readers. |
| Close | Focus returns to trigger element |
Sheet
A side panel overlay — structurally identical to Dialog in accessibility. Uses Radix Dialog internally.
Role & ARIA
Same as Dialog: role="dialog", aria-modal="true", aria-labelledby, aria-describedby.
<Sheet>
<SheetTrigger asChild>
<Button>Open Settings</Button>
</SheetTrigger>
<SheetContent side="right">
{/* Radix renders dialog role and ARIA automatically */}
<SheetHeader>
<SheetTitle>Settings</SheetTitle>
<SheetDescription>Adjust your preferences.</SheetDescription>
</SheetHeader>
{/* ... content ... */}
</SheetContent>
</Sheet>
Focus Behavior
Identical to Dialog:
- Opens: Focus moves to first focusable element inside the sheet
- Trapped: Tab/Shift+Tab cannot leave the sheet
- Closes: Focus returns to
SheetTrigger
Close Behavior
Same as Dialog:
- Escape closes the sheet
- Click on overlay backdrop closes the sheet
- Close button (X) needs
aria-label="Close"
Animation and Reduced Motion
The sheet slides in from the side. Ensure:
- The slide animation respects
prefers-reduced-motion: reduce— either disable animation or use a simple fade - Animation does not delay focus movement — focus should land inside the sheet immediately, not after the transition completes
Drawer
A bottom or side drawer, typically used on mobile. Built with Vaul which wraps Radix Dialog patterns.
Role & ARIA
Same as Dialog: role="dialog", aria-modal="true", aria-labelledby, aria-describedby.
<Drawer>
<DrawerTrigger asChild>
<Button>Open Drawer</Button>
</DrawerTrigger>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>Move Goal</DrawerTitle>
<DrawerDescription>Set your daily activity goal.</DrawerDescription>
</DrawerHeader>
{/* ... content ... */}
<DrawerFooter>
<Button>Submit</Button>
<DrawerClose asChild>
<Button variant="outline">Cancel</Button>
</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
Focus Behavior
Identical to Dialog:
- Opens: Focus moves to first focusable element inside the drawer
- Trapped: Tab/Shift+Tab cannot leave
- Closes: Focus returns to
DrawerTrigger
Drag-to-Close Gesture
Vaul supports drag-to-close (swipe down on mobile). This gesture must have a keyboard equivalent:
- Escape closes the drawer (inherits from Radix Dialog)
- A visible close/cancel button must be present for users who cannot perform drag gestures
- The drag handle (if visible) should be
aria-hidden="true"— it's a visual affordance, not an interactive element for assistive technology
Animation and Reduced Motion
Same requirements as Sheet:
- Respect
prefers-reduced-motion: reduce - Don't delay focus movement behind animations
Stacked/Nested Overlays
When one overlay opens another (e.g., a Dialog opens an AlertDialog):
- The inner overlay traps focus — the outer overlay is effectively inert
- When the inner overlay closes, focus returns to the element in the outer overlay that triggered it
- When the outer overlay closes, focus returns to the original page trigger
- Screen readers should announce the new dialog role/title at each layer
- Avoid deeply nested overlays — more than 2 layers deep creates a confusing experience
Common Mistakes
| Mistake | Why It's a Problem | Correct Approach |
|---|---|---|
Missing DialogTitle / SheetTitle | Screen reader announces "dialog" with no context — user doesn't know what opened | Always include a title. If you don't want it visible, use className="sr-only" on the title element. |
Close button (X) without aria-label | Screen reader announces "button" with no context | Add aria-label="Close" to the close button |
| Focus not returning to trigger on close | User loses their place on the page | Use Radix primitives as-is — they handle this. Don't override onCloseAutoFocus without setting focus elsewhere. |
| Using Dialog when AlertDialog is needed | Users can accidentally dismiss a destructive confirmation via Escape or backdrop click | Use AlertDialog for any action that is destructive or irreversible |
Visible title missing but no sr-only title | aria-labelledby points to nothing, or console warning about missing title | Radix warns when title is missing. Always provide one, even if visually hidden. |
| Content behind overlay is scrollable/interactive | Screen reader users can navigate to content "behind" the modal | Radix applies aria-modal="true" and makes content inert. Verify this works with your portal setup. |
| Animation delays focus movement | User presses Tab before overlay is "ready" and focus goes somewhere unexpected | Ensure focus is moved to the overlay immediately, not after CSS transition ends |
Using autoFocus on a destructive action button | In AlertDialog, user could accidentally press Enter on the destructive action | AlertDialog should focus the Cancel (safe) button first |
| Overlay without description for complex content | Screen reader only announces the title — user doesn't know what to expect | Add DialogDescription / AlertDialogDescription for context |
Testing Checklist
Screen Reader Testing
- Dialog/AlertDialog role announced on open ("dialog" / "alertdialog")
- Title announced on open (via
aria-labelledby) - Description announced on open (via
aria-describedby), if present - Focus is inside the overlay — screen reader cannot read page content behind it
- Closing returns focus to trigger and trigger element is announced
- AlertDialog: Escape does NOT close it
Keyboard Testing
- Tab cycles through focusable elements inside the overlay only
- Shift+Tab cycles backwards through overlay content only
- Cannot Tab to elements outside the overlay (focus trap working)
- Escape closes Dialog, Sheet, Drawer
- Escape does NOT close AlertDialog
- Enter activates the focused button
- After close, focus returns to the trigger element
Visual Testing
- Focus ring visible on all interactive elements inside the overlay
- Background/backdrop visually obscures page content
- Sheet/Drawer animation respects
prefers-reduced-motion - Close button (X) has a visible focus indicator
References
- WAI-ARIA Dialog (Modal) Pattern
- WAI-ARIA AlertDialog Pattern
- Radix Dialog Primitive
- Radix AlertDialog Primitive
- Vaul Drawer
Last updated: February 9, 2026