Focus management patterns
Focus management patterns define how keyboard navigation and focus behavior work across components. These patterns ensure accessible, predictable interactions for all users, including those who rely on keyboard navigation.
Component patterns
Modal dialog
Behavior:
- Focus traps within dialog
- Focus moves to first interactive element on open
- Escape closes dialog
- Focus returns to trigger element on close
Common mistakes:
- Not trapping focus, allowing Tab to exit modal
- Not restoring focus to trigger on close
- Missing
aria-modal="true"
Alert dialog (critical)
For destructive or critical confirmations where Escape should NOT close.
Key difference: Focus the LEAST destructive option (Cancel) by default.
Dropdown menu
Keyboard navigation:
- ↑/↓: move between items (wraps at boundaries)
- Enter / Space: activate item
- Escape: close menu
- Tab: exit menu (does NOT navigate items)
- Home / End: jump to first/last item
- Type character: jump to item starting with that letter
Tabs
{tabs.map((tab, index) => (
))}
{tabs.map((tab, index) => (
{tab.content}
))}
Keyboard navigation:
- ←/→: move between tabs (wraps)
- Usually automatic activation (arrow changes tab)
- Tab key: exit tablist to panel content
Toolbar
{tools.map((tool, index) => (
))}
Key behavior: Toolbar is ONE tab stop. Use arrow keys to move between items.
Combobox / select
{isOpen && (
{options.map((option) => (
-
{option.label}
))}
)}
Key difference: Arrow keys do NOT wrap: stop at first/last option.
Skip links
Skip to main content
{/* Later in the page */}
{/* Content */}
Requirements:
- Must be first focusable element on page
- Hidden until focused
- Target needs
tabIndex="-1"to receive programmatic focus
Focus rules
Visibility rules
| Rule | Requirement | WCAG |
|---|---|---|
| Visible focus indicator | All interactive elements must show focus | 2.4.7 (AA) |
| Use focus-visible | Show focus only on keyboard nav, not clicks | Best practice |
| Focus contrast | Focus indicator must have 3:1 contrast against adjacent colours | 1.4.11 (AA) |
| Focus appearance | Indicator area and contrast thresholds | 2.4.13 (AAA) |
The exact ring (width, colour, whether it carries an offset) is the design
system's to declare, so check get_interaction_states (or get_design_tokens
for the token values) rather than copying a fixed value. get_focus_pattern is
not a reliable source for it: its shape is focus management behaviour plus
focusRing.focusVisibleOnly (a boolean) and focusRing.style, passed through
from your design system's declared focus ring style. There is no width, colour or
offset field of its own, and whether it returns anything for a given component
depends on what your design system declared. If your resolved row declares an offset
deliberately, that's correct to ship for your design system. Museum itself
doesn't use one: the ring is a box-shadow, so an offset moves its effective
radius away from the element's own corner, and an un-themed offset colour
shows as a white halo in dark mode. Weigh that tradeoff for your own system
rather than assuming offset is forbidden everywhere.
// Good: visible focus, no offset (Museum's declared ring shown)
className="focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-foreground/50"
// Bad: hidden focus
className="focus:outline-none" // Without replacement
// Bad for Museum specifically: an offset moves the ring off the element's
// own radius, and Museum's un-themed offset colour is a white halo in dark
// mode. A design system that declares its own themed offset is not wrong to.
className="focus-visible:ring-1 focus-visible:ring-foreground/50 focus-visible:ring-offset-2"
Order rules
| Rule | Requirement | WCAG |
|---|---|---|
| Logical tab order | Must follow visual / reading order | 2.4.3 (A) |
| No positive tabindex | Never use tabindex="1", "2", etc. | 2.4.3 (A) |
| Skip links first | Skip links must be first focusable | 2.4.1 (A) |
// Good
tabIndex="0" // Focusable in natural order
tabIndex="-1" // Programmatic focus only
// Bad
tabIndex="5" // Creates unpredictable order
tabIndex="99" // Maintenance nightmare
Focus trap rules
| Rule | Requirement | WCAG |
|---|---|---|
| Modal focus trap | Focus must cycle within modal | 2.4.3 (A) |
| Focus return | Return focus to trigger on close | 2.4.3 (A) |
| Escape closes | Escape should close overlays (except critical) | Best practice |
Landmark regions
Standard landmark regions for skip link navigation:
| Name | Role | HTML Element | Skip Link |
|---|---|---|---|
| main | main | <main> | "Skip to main content" |
| navigation | navigation | <nav> | "Skip to navigation" |
| search | search | <search> | "Skip to search" |
| banner | banner | <header> | : |
| contentinfo | contentinfo | <footer> | : |
MCP tool usage
Get focus pattern
// Get patterns for modal components
get_focus_pattern({ componentType: 'modal' })
// Get specific pattern
get_focus_pattern({ name: 'alert-dialog' })
// Include rules and landmarks
get_focus_pattern({
componentType: 'modal',
includeRules: true,
includeLandmarks: true
})
Validate focus management
// Basic validation
validate_focus_management({
code: ''
})
// With component type context
validate_focus_management({
code: '',
componentType: 'dropdown'
})
// Strict mode (warnings become errors)
validate_focus_management({
code: '...',
strict: true
})
Validation checks
| Issue | Severity | Fix |
|---|---|---|
| Missing focus indicator | Error | Add the design system's declared focus ring (e.g. focus-visible:ring-1 focus-visible:ring-foreground/50), never focus: or a hand-picked ring-* value |
| Positive tabindex | Error | Use tabindex="0" or -1 |
| Modal without focus trap | Error | Use focus trap library + aria-modal |
| Dialog missing aria-modal | Warning | Add aria-modal="true" |
| Tab missing aria-selected | Error | Add aria-selected attribute |
| Click without keyboard | Warning | Add role="button" + keyboard handler |
| autoFocus usage | Warning | Ensure intentional and appropriate |
Quick reference
Keyboard navigation by component
| Component | Navigation | Wrap? | Activation |
|---|---|---|---|
| Modal | Tab | Trap | Enter / Space |
| Menu | Arrow | Yes | Enter / Space |
| Tabs | Arrow | Yes | Auto or Enter |
| Toolbar | Arrow | Yes | Enter / Space |
| Combobox | Arrow | No | Enter |
| Tree | Arrow | No | Enter |
Required ARIA by component
| Component | Required attributes |
|---|---|
| Modal | role="dialog", aria-modal, aria-labelledby |
| Alert Dialog | role="alertdialog", aria-modal, aria-labelledby, aria-describedby |
| Menu | role="menu", aria-labelledby |
| Menu Item | role="menuitem" |
| Tabs | role="tablist", aria-orientation |
| Tab | role="tab", aria-selected, aria-controls |
| Tab Panel | role="tabpanel", aria-labelledby |
| Combobox | role="combobox", aria-haspopup, aria-expanded, aria-controls |
Decision guide
Building interactive component?
├── Is it an overlay?
│ ├── Modal / blocking? → Use focus trap + aria-modal
│ └── Non-modal? → No trap, but restore focus on close
├── Is it a list of options?
│ ├── Actions? → role="menu" + arrow keys + wrap
│ ├── Selection? → role="listbox" + arrow keys + no wrap
│ └── Navigation? → role="tablist" + arrow keys + wrap
├── Is it a button group?
│ └── Toolbar? → role="toolbar" + arrow keys + one tab stop
└── Is it page-level?
└── Add skip links (first focusable element)