Dialog, Sheet, Popover: Accessibility and Focus Management for Overlay Components
At 2 AM, a client sent an email: “When your website’s overlay opens, pressing Tab moves focus to the background page. Keyboard users can’t operate it at all.”
Honestly, I felt pretty embarrassed reading this email—because I wrote that overlay last week.
I opened the code, and the problem was obvious: after the Dialog opened, focus remained on the background button. Naturally, pressing Tab moved focus to the background page. Screen reader users were worse off—they had no idea the overlay had opened because focus hadn’t moved inside, and ARIA attributes weren’t set.
This article covers accessibility and focus management for Dialog, Sheet, and Popover overlay components. Honestly, I hope you can avoid the pitfalls I’ve stumbled into.
First, Understand the Core Differences Between These Three Components
Honestly, many people—including me before—were pretty fuzzy about the differences between these three components. Just overlays, right? They’re all the same. But actually, their core differences determine how accessibility is handled.
Dialog (Modal Dialog)
Dialog is a modal overlay that completely blocks background interaction.
Example: you click a “Delete Order” button, and a confirmation dialog appears. The background page is covered by an overlay mask, and you can’t click any background elements—this is Dialog’s core feature: forcing users to handle the current task.
Use cases:
- Important prompts (delete confirmation, operation warnings)
- Form filling (login box, registration form)
- Operations requiring immediate user response
Accessibility key: aria-modal="true" must be set, focus trap must be implemented.
Sheet (Side Drawer)
Sheet is a drawer-style panel sliding from the screen edge. Essentially, it’s the same as Dialog—both are modal overlays that block background interaction and require focus traps. The only difference is visual position: Sheet slides from the side, Dialog appears centered.
Use cases:
- Navigation menu (mobile sidebar)
- Settings panel (preferences, theme switching)
- Detail display (product details, article preview)
Honestly, “Sheet” was unfamiliar to me at first. Later I discovered it’s just another name for Drawer—some UI libraries call it Drawer, some call it Sheet. Radix UI and shadcn/ui use Sheet.
Accessibility key: same as Dialog—aria-modal="true"、focus trap、Esc to close.
Popover (Popup)
Popover is a non-modal overlay that doesn’t block background interaction.
This is key—after Popover opens, users can still click background elements. Focus isn’t forcibly restricted inside the Popover.
Example: you click a “More Actions” button, and a small panel containing “Edit”, “Copy”, “Delete” appears. That’s Popover. You can click other background buttons, and Popover automatically closes.
Use cases:
- Dropdown menus (action menus, option lists)
- Tooltips (rich text hints, usage instructions)
- Quick actions (edit, copy, delete)
Accessibility key: aria-modal="false" (or omit), focus not forcibly trapped, click outside to close.
A Table to See the Differences Clearly
Honestly, I was checking this table while writing it—some concepts were indeed fuzzy before.
| Feature | Dialog | Sheet | Popover |
|---|---|---|---|
| Blocks background | ✅ Must block | ✅ Must block | ❌ Doesn’t block |
| Focus trap | Must implement | Must implement | Optional (recommended not to force) |
| Esc to close | Must support | Must support | Recommended to support |
| Click outside to close | Optional | Optional | Default behavior |
| ARIA role | dialog | dialog | popover |
aria-modal | "true" | "true" | "false" or omit |
| Visual position | Centered | Slides from side | Positioned relative to trigger |
One sentence summary: Dialog and Sheet are modal overlays, Popover is non-modal. Modal overlays must implement focus traps; non-modal overlays don’t have to force it.
WCAG Accessibility Standards Deep Dive
Honestly, WCAG standards seemed pretty dry at first. A bunch of English terms, reading like legal documents. But after encountering problems in real projects, I discovered these standards are actually useful—not for passing inspections, but for letting users operate normally.
Required ARIA Attributes
Overlay components have three required ARIA attributes:
1. role="dialog"
This attribute tells assistive technologies (screen readers): this is a dialog.
<div role="dialog">
<!-- Overlay content -->
</div>
2. aria-labelledby
This attribute associates the overlay’s title element. When screen readers open the overlay, they first announce the title.
<div role="dialog" aria-labelledby="dialog-title">
<h2 id="dialog-title">Confirm Delete</h2>
<p>This action cannot be undone.</p>
</div>
3. aria-modal="true" (modal overlays only)
This attribute tells screen readers: background content is inaccessible.
<div role="dialog" aria-modal="true">
<!-- Modal overlay content -->
</div>
Honestly, I often forgot aria-labelledby before. Later when testing with screen readers, I realized—without this attribute, users opening the overlay hear nothing, not knowing what this is.
Keyboard Navigation Requirements
WCAG has clear requirements for overlay keyboard navigation:
Tab key: Cycle focus within the overlay
When users press Tab, focus should cycle among interactive elements inside the overlay, not escape to the background page.
Shift+Tab: Reverse cycle focus
When users press Shift+Tab, focus cycles backwards.
Esc key: Close overlay
When users press Esc, the overlay should close. This is mandatory—some users habitually close overlays with Esc. If not supported, they’re trapped inside.
Enter/Space: Activate buttons
These keys activate buttons or links.
Honestly, I stumbled into the Tab cycling pitfall before. After overlay opened, focus wasn’t restricted inside. Users pressing Tab moved focus to the background page—this was exactly the client complaint I mentioned at the start.
Focus Management Standards
Focus management is the most overlooked part of overlay accessibility. WCAG’s requirements are simple:
When opening overlay:
Focus should move to the first interactive element inside the overlay (usually the close button or first input).
When closing overlay:
Focus should restore to the trigger element (the button that opened the overlay).
Honestly, I didn’t realize focus restoration after closing before. Later when testing with keyboard, I discovered—after overlay closed, focus went somewhere unknown. Keyboard users had to search again. This experience was really terrible.
Special case:
If the overlay has important prompt content (like operation instructions), focus should first land on the container element, letting screen readers announce the prompt content before users operate.
The approach is adding tabindex="0" to the container:
<div role="dialog" aria-modal="true" tabindex="0">
<h2>Operation Instructions</h2>
<p>Please read the following content carefully before operating...</p>
<button>Confirm</button>
</div>
This way, when overlay opens, focus first lands on the container. Screen readers announce the entire content first, then let users Tab to the button.
Focus Trap Implementation Principles
Honestly, focus trap sounds complex, but the principle is simple—just make Tab cycle within the overlay.
What is a Focus Trap
Focus trap definition: restricting user’s Tab navigation to cycle within a specific area.
Example: after overlay opens, user presses Tab, focus jumps from “Close button” to “Confirm button”. Pressing Tab again, focus returns to “Close button”—this is a focus trap.
Necessity: prevent users from accidentally operating background content. If focus can escape to the background page, users might unintentionally trigger background buttons, causing accidental operations.
JavaScript Implementation Approach
Focus trap’s core logic is simple: find all interactive elements inside the overlay, listen for Tab key, cycle between first and last elements.
function trapFocus(modal) {
// Find all interactive elements
const focusableElements = modal.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
// Listen for keyboard events
modal.addEventListener('keydown', (e) => {
if (e.key === 'Tab') {
// Shift+Tab: at first element, jump to last
if (e.shiftKey && document.activeElement === firstElement) {
e.preventDefault();
lastElement.focus();
}
// Tab: at last element, jump to first
else if (!e.shiftKey && document.activeElement === lastElement) {
e.preventDefault();
firstElement.focus();
}
}
// Esc closes overlay
if (e.key === 'Escape') {
closeModal();
}
});
}
Honestly, I wrote this code several times before it worked. Main pitfalls:
focusableElementsselector must be complete—missing any type lets focus escapee.preventDefault()must be called, otherwise browser default behavior moves focus out
focus-trap Library Introduction
If you don’t want to write focus trap yourself, use an existing library—focus-trap-react.
import FocusTrap from 'focus-trap-react';
<FocusTrap>
<div className="modal">
<button>Close</button>
<button>Confirm</button>
</div>
</FocusTrap>
This library automatically handles focus cycling, Esc closing, nested overlays, etc.
Honestly, I basically don’t use this library anymore—because shadcn/ui has integrated focus management internally. Radix UI (shadcn/ui’s underlying layer) automatically handles all focus trap logic, no need for extra libraries.
shadcn/ui Practice: Dialog Implementation
Honestly, after using shadcn/ui, I never hand-write overlay components anymore. Not because I’m lazy, but because—hand-written overlays always have accessibility issues, while shadcn/ui built on Radix UI automatically handles all accessibility details.
Installation and Basic Usage
npx shadcn@latest add dialog
After installation, components/ui/dialog.tsx file is automatically generated.
Complete Code Example
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
export function DeleteConfirmDialog() {
return (
<Dialog>
<DialogTrigger asChild>
<Button variant="outline">Delete Order</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Confirm Delete</DialogTitle>
<DialogDescription>
This action cannot be undone. Are you sure you want to delete this order?
</DialogDescription>
</DialogHeader>
<div className="flex justify-end gap-2 mt-4">
<Button variant="outline">Cancel</Button>
<Button variant="destructive">Delete</Button>
</div>
</DialogContent>
</Dialog>
)
}
Honestly, this code looks simple, but behind it Radix UI automatically handles many details:
- When overlay opens, focus moves to first button (“Cancel”)
- When overlay closes, focus restores to “Delete Order” button
- Tab cycles within overlay
- Esc closes overlay
aria-labelledbyautomatically associatesDialogTitlearia-describedbyautomatically associatesDialogDescription
Key Accessibility Features
1. Automatic Focus Management
Radix UI’s Dialog automatically moves focus to the first interactive element inside overlay when opening. When closing, focus automatically restores to trigger element.
2. Automatic ARIA Attribute Association
DialogTitle automatically associates aria-labelledby, DialogDescription automatically associates aria-describedby.
<!-- Radix UI generated HTML -->
<div role="dialog" aria-modal="true" aria-labelledby="radix-:r1:" aria-describedby="radix-:r2:">
<h2 id="radix-:r1:">Confirm Delete</h2>
<p id="radix-:r2:">This action cannot be undone...</p>
</div>
Honestly, these details are easily missed when hand-written. Using shadcn/ui, no worries at all.
3. Automatic Esc Key Closing
Pressing Esc automatically closes overlay, focus automatically restores to trigger element.
4. Click Overlay Mask to Close
Clicking the overlay mask (gray background outside overlay) also closes it. This behavior can be blocked via DialogContent’s onInteractOutside property.
<DialogContent onInteractOutside={(e) => e.preventDefault()}>
<!-- Clicking mask won't close overlay -->
</DialogContent>
shadcn/ui Practice: Sheet Implementation
Sheet’s accessibility features are identical to Dialog. The only difference is visual position—Sheet slides from the side.
Installation and Basic Usage
npx shadcn@latest add sheet
Complete Code Example
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet"
import { Button } from "@/components/ui/button"
export function NavigationSheet() {
return (
<Sheet>
<SheetTrigger asChild>
<Button variant="outline">Open Menu</Button>
</SheetTrigger>
<SheetContent side="left">
<SheetHeader>
<SheetTitle>Navigation Menu</SheetTitle>
<SheetDescription>
Select the page you want to visit
</SheetDescription>
</SheetHeader>
<nav className="flex flex-col gap-4 mt-4">
<a href="/" className="hover:underline">Home</a>
<a href="/about" className="hover:underline">About</a>
<a href="/contact" className="hover:underline">Contact</a>
</nav>
</SheetContent>
</Sheet>
)
}
Differences from Dialog
Honestly, Sheet and Dialog code is almost identical, just component names differ. Main differences:
1. Slide Animation
Sheet slides from right by default, can control direction via side property:
<SheetContent side="left"> <!-- Slide from left -->
<SheetContent side="right"> <!-- Slide from right (default) -->
<SheetContent side="top"> <!-- Slide from top -->
<SheetContent side="bottom"> <!-- Slide from bottom -->
2. Same Accessibility Features
Sheet’s accessibility features are identical to Dialog:
role="dialog"aria-modal="true"- Focus trap, Esc closing, focus restoration
Honestly, I mainly use Sheet for mobile navigation menus. The slide-from-side visual effect better matches mobile interaction habits.
shadcn/ui Practice: Popover Implementation
Popover is a non-modal overlay. The core difference from Dialog and Sheet is: doesn’t block background interaction.
Installation and Basic Usage
npx shadcn@latest add popover
Complete Code Example
import {
Popover,
PopoverContent,
PopoverHeader,
PopoverTitle,
PopoverDescription,
PopoverTrigger,
} from "@/components/ui/popover"
import { Button } from "@/components/ui/button"
export function ActionPopover() {
return (
<Popover>
<PopoverTrigger asChild>
<Button variant="outline">More Actions</Button>
</PopoverTrigger>
<PopoverContent>
<PopoverHeader>
<PopoverTitle>Quick Actions</PopoverTitle>
<PopoverDescription>
Select an action below
</PopoverDescription>
<PopoverHeader>
<div className="flex flex-col gap-2 mt-2">
<Button size="sm">Edit</Button>
<Button size="sm">Copy</Button>
<Button size="sm" variant="destructive">Delete</Button>
</div>
</PopoverContent>
</Popover>
)
}
Key Differences
Honestly, Popover code looks similar to Dialog and Sheet, but the behavior behind is completely different:
1. Non-modal
After Popover opens, users can still click background elements. Focus isn’t forcibly restricted inside Popover.
2. Focus Not Forced
When users press Tab, focus can escape from Popover to background elements. This is completely different from Dialog.
3. Click Outside to Close
Clicking any element outside Popover automatically closes it. This is default behavior, can be blocked via onInteractOutside property.
<PopoverContent onInteractOutside={(e) => e.preventDefault()}>
<!-- Clicking outside won't close -->
</PopoverContent>
4. Flexible Positioning
Popover controls horizontal alignment via align property:
<PopoverContent align="start"> <!-- Left align -->
<PopoverContent align="center"> <!-- Center align (default) -->
<PopoverContent align="end"> <!-- Right align -->
Honestly, I mainly use Popover for action menus—clicking a button pops up several quick action options. This scenario doesn’t need to block background interaction, Popover fits perfectly.
Advanced Tips and Common Pitfalls
Honestly, I’ve stumbled into quite a few overlay component pitfalls. Here are the most common ones.
Focus Restoration Pitfall: Trigger Element Deleted
Scenario: after overlay opens, trigger element (button) gets deleted. When overlay closes, focus has nowhere to restore.
Solutions:
- Don’t delete trigger element, just hide it
- Or record a focus restoration target element
const [triggerElement, setTriggerElement] = useState<HTMLElement | null>(null);
// Record trigger element when opening overlay
const handleOpen = (e: React.MouseEvent<HTMLButtonElement>) => {
setTriggerElement(e.currentTarget);
setOpen(true);
};
// Restore focus when closing overlay
const handleClose = () => {
setOpen(false);
triggerElement?.focus();
};
Honestly, I stumbled into this pitfall. After user deleted a record, overlay closed, focus went somewhere unknown. Later I restored focus to the previous record in the list.
Screen Reader Pitfall: Overlay Content Not Announced
Scenario: after overlay opens, screen reader doesn’t announce overlay content. Users don’t know what’s in the overlay.
Causes:
- Missing
aria-labelledbyattribute - Focus didn’t move into overlay
Solution:
Ensure both DialogTitle and DialogDescription are set. shadcn/ui automatically associates ARIA attributes.
<DialogContent>
<DialogHeader>
<DialogTitle>Confirm Delete</DialogTitle> <!-- Must have -->
<DialogDescription>This action cannot be undone</DialogDescription> <!-- Must have -->
</DialogHeader>
</DialogContent>
Honestly, I often missed DialogDescription before. Later testing with NVDA (screen reader), I discovered—without description, users only know overlay title, not specific content.
Nested Overlay Pitfall: Focus Management Chaos
Scenario: Overlay A opens Overlay B. After closing B, focus goes somewhere unknown.
Solution:
Radix UI’s Dialog and Sheet support nesting. When inner overlay closes, focus restores to inner overlay’s trigger element (possibly a button inside outer overlay).
<Dialog>
<DialogTrigger>Open Overlay A</DialogTrigger>
<DialogContent>
<DialogTitle>Overlay A</DialogTitle>
<!-- Open Overlay B inside Overlay A -->
<Dialog>
<DialogTrigger>Open Overlay B</DialogTrigger>
<DialogContent>
<DialogTitle>Overlay B</DialogTitle>
</DialogContent>
</Dialog>
</DialogContent>
</Dialog>
Honestly, I avoid nested overlays whenever possible. When really needed, I use Radix UI’s nesting support, letting it automatically handle focus.
Animation Delay Pitfall: Focus Not Inside Overlay
Scenario: overlay has animation (like fade-in), during animation focus isn’t inside overlay.
Cause:
When animation starts, overlay isn’t fully visible yet, focus setup fails.
Solution:
Radix UI automatically handles this. Focus is set only after animation completes.
If implementing yourself, need to wait for animation completion:
modal.addEventListener('animationend', () => {
const firstFocusable = modal.querySelector('button, [href], input');
firstFocusable?.focus();
});
Honestly, I stumbled into this pitfall. Hand-written overlay, when opening, focus remained on background button because I tried setting focus before animation completed. Later adding animationend event listener solved it.
Summary
After all this discussion, the core is actually three points:
1. Core differences between three component types
Dialog and Sheet are modal overlays—block background interaction, must have focus trap.
Popover is non-modal overlay—doesn’t block background interaction, focus not forced.
2. WCAG accessibility three requirements
ARIA attributes (role="dialog", aria-labelledby, aria-modal="true")
Keyboard navigation (Tab cycling, Shift+Tab reverse, Esc closing)
Focus management (focus when opening, restore when closing)
3. shadcn/ui handles all details automatically
Radix UI automatically handles focus trap, ARIA attributes, keyboard navigation. Using shadcn/ui, basically no need to worry about accessibility issues.
Honestly, after writing so many overlay components, my principle is simple: production environments prioritize shadcn/ui. Hand-written overlay components always have endless accessibility issues, while shadcn/ui built on Radix UI handles all these details.
The only thing to note: understand the principles. Knowing what Radix UI does behind the scenes lets you quickly locate problems when they occur.
References
- WAI-ARIA dialog role - MDN
- Radix UI Accessibility
- WCAG 2.1 Quick Reference
- Mastering Accessible Modals
- focus-trap-react
FAQ
What are the differences between Dialog, Sheet, and Popover components?
What accessibility requirements must overlay components implement?
What is a focus trap? Why must modal overlays implement it?
What accessibility details does shadcn/ui's Dialog component automatically handle?
• When overlay opens, focus automatically moves to first interactive element
• When overlay closes, focus automatically restores to trigger element
• Tab cycles within overlay
• Esc key automatically closes overlay
• aria-labelledby automatically associates DialogTitle
• aria-describedby automatically associates DialogDescription
Where should focus restore after overlay closes?
What to do when focus setup fails due to overlay animation?
How to make screen readers announce overlay content?
12 min read · Published on: Mar 29, 2026 · Modified on: Mar 29, 2026
Related Posts
Tailwind Dark Mode: class vs data-theme Strategy Comparison
Tailwind Dark Mode: class vs data-theme Strategy Comparison
Building Admin Skeleton with shadcn/ui: Sidebar + Layout Best Practices
Building Admin Skeleton with shadcn/ui: Sidebar + Layout Best Practices
Tailwind Responsive Layout Practice: Container Queries and Breakpoint Strategies

Comments
Sign in with GitHub to leave a comment