brainstorming
brainstorming
You MUST use this before any creative work - creating features, building components, adding functionality, modifying behavior, or planning anything new, in software or out of it (a talk, a business, a renovation).
wcag-keyboard-navigation-focus-managementImplements WCAG 2.2 AA keyboard navigation (2.1.1) and focus management (2.4.3, 2.4.7, 2.4.11, 2.1.2) patterns including logical tab order, skip links, focus visible, focus traps, and roving tabIndex for accessible web interfaces.
Independent GitHub snapshot · Automated classification · Original documentation governs
Implements keyboard accessibility patterns for WCAG 2.2 AA compliance, enabling full interface navigation via Tab, Shift+Tab, arrow keys, and Escape. Makes keyboard-only users first-class citizens by controlling focus flow, preventing traps, and ensuring all interactive elements are discoverable and visually identifiable.
Use this skill when:
Avoid this skill for:
<a> tags are keyboard-accessible by default)<input>, <select>, <textarea> elements without custom styling (browser handles tab order automatically)Audit Current Tab Order — Use browser dev tools (Tab key + Chrome DevTools Element Inspector) to trace logical focus flow. Checkpoint: Does focus move left-to-right, top-to-bottom? Do hidden elements trap focus?
Implement Skip Links — Add first keyboard stop as a hidden-until-focused link to "#main" content. Checkpoint: Pressing Tab reveals skip link; clicking goes to main content, not navigation.
Apply Semantic HTML — Use native elements (<button>, <a>, <form>, <fieldset>) before custom divs. Set tabindex="0" only for custom interactive elements; never tabindex="1" or higher (breaks logical order). Checkpoint: Tab order matches visual layout.
Add Focus Visible Styling — Apply :focus-visible with minimum 3px outline, 3:1 contrast ratio. Test with Windows High Contrast Mode. Checkpoint: Focus indicator visible at 3:1 contrast; outline doesn't disappear on focus.
Implement Roving TabIndex (Composite Widgets) — For tabs/menus/listboxes: only one child has tabindex="0", others have tabindex="-1". Arrow keys move focus between children, Tab exits widget. Checkpoint: Tab once enters widget at first item; arrow keys navigate; Tab again exits.
Trap Focus in Modals — Store initial focus, focus first focusable element in modal, trap Tab/Shift+Tab within modal, restore focus on close. Checkpoint: Tab cycles within modal only; Escape closes and restores focus.
Prevent Focus Obscured — Test: tab through page, verify sticky headers don't cover the focused element (test tab backwards through all elements too). Checkpoint: 2.4.11 AA compliance: focused element visible with ≥0px visible space; 2.4.12 AAA: ≥6px visible space.
Test with Keyboard Only — Disable mouse, navigate entire interface with Tab, Shift+Tab, arrow keys, Enter, Escape. Checkpoint: Every interactive element reachable; no focus lost; no infinite loops.
Purpose: Allow keyboard users to bypass repetitive navigation and jump to main content on first Tab press.
When: Always include skip links for any page with navigation menus.
// HTML structure
<body>
{/* Skip link - invisible until focused */}
<a href="#main-content" className="skip-link">
Skip to main content
</a>
<header>
<nav>{/* Navigation menu */}</nav>
</header>
{/* Target for skip link */}
<main id="main-content" tabIndex={-1}>
{/* Page content */}
</main>
</body>
// CSS for skip link - visible on focus, positioned off-screen by default
.skip-link {
position: absolute;
left: -9999px;
top: 0;
z-index: 999;
padding: 12px 16px;
background-color: #000;
color: #fff;
text-decoration: none;
border-radius: 4px;
font-weight: 600;
}
.skip-link:focus {
left: 50%;
transform: translateX(-50%);
outline: 3px solid #4A90E2;
outline-offset: 2px;
}
Key Points:
#main-content) must have tabIndex={-1} so it's programmatically focusable:focus reveals itPurpose: Provide clear visual focus indicator meeting minimum contrast and visibility requirements.
Requirements: ≥3px outline, 3:1 contrast ratio minimum (AA), ≥6px (AAA).
// Global focus styles - apply to ALL interactive elements
const focusVisibleStyles = `
/* Native elements */
button:focus-visible,
a:focus-visible,
input:focus-visible,
select:focus-visible,
textarea:focus-visible,
[role="button"]:focus-visible,
[role="link"]:focus-visible,
[role="tab"]:focus-visible,
[role="menuitem"]:focus-visible,
[role="option"]:focus-visible {
outline: 3px solid #4A90E2; /* 4.5:1 contrast on white bg */
outline-offset: 2px;
}
/* Dark mode adjustment */
@media (prefers-color-scheme: dark) {
button:focus-visible,
a:focus-visible,
input:focus-visible,
select:focus-visible,
textarea:focus-visible,
[role="button"]:focus-visible,
[role="link"]:focus-visible,
[role="tab"]:focus-visible,
[role="menuitem"]:focus-visible,
[role="option"]:focus-visible {
outline-color: #7BB3FF; /* Light blue for dark backgrounds */
}
}
/* Ensure outline visible on form inputs */
input:focus-visible,
textarea:focus-visible,
select:focus-visible {
outline: 3px solid #4A90E2;
outline-offset: 2px;
/* Remove default browser outline if set */
box-shadow: none;
}
/* Windows High Contrast Mode support */
@media (prefers-contrast: more) {
button:focus-visible,
a:focus-visible,
input:focus-visible {
outline: 4px solid;
outline-offset: 3px;
}
}
`;
// React hook: ensure component has focus-visible styling
export const useFocusVisible = (ref: React.RefObject<HTMLElement>) => {
useEffect(() => {
const element = ref.current;
if (!element) return;
// Verify :focus-visible styles are applied
const styles = window.getComputedStyle(element, ':focus-visible');
const outlineWidth = styles.outlineWidth;
if (!outlineWidth || outlineWidth === '0px') {
console.warn(
`Focus visible outline not detected on ${element.tagName}. ` +
'Ensure CSS includes :focus-visible rules.'
);
}
}, [ref]);
};
Contrast Verification:
:focus state, measure outline color vs. background#4A90E2 blue on white background(prefers-contrast: more) media queryPurpose: Trap focus inside modal dialog, preventing keyboard user from navigating outside. Release trap on close and restore initial focus.
Key Requirement: Modal must trap focus intentionally and provide clear escape mechanism (Escape key or explicit Close button).
// React hook: manage focus trap in modal
export const useFocusTrap = (
modalRef: React.RefObject<HTMLDivElement>,
onClose: () => void,
isOpen: boolean
) => {
const [initialFocus, setInitialFocus] = React.useState<HTMLElement | null>(null);
useEffect(() => {
if (!isOpen || !modalRef.current) return;
// 1. Store element that had focus before modal opened
setInitialFocus(document.activeElement as HTMLElement);
// 2. Get all focusable elements inside modal
const getFocusableElements = (): HTMLElement[] => {
const focusableSelectors = [
'a[href]',
'button:not([disabled])',
'input:not([disabled])',
'select:not([disabled])',
'textarea:not([disabled])',
'[tabindex]:not([tabindex="-1"])',
].join(',');
return Array.from(
modalRef.current?.querySelectorAll(focusableSelectors) ?? []
) as HTMLElement[];
};
const focusableElements = getFocusableElements();
if (focusableElements.length === 0) {
console.warn('Modal has no focusable elements; trap may not work as expected.');
return;
}
// 3. Focus first focusable element on modal open
const firstElement = focusableElements[0];
firstElement.focus();
// 4. Handle Tab/Shift+Tab to cycle focus within modal
const handleKeyDown = (event: KeyboardEvent) => {
const isTabKey = event.key === 'Tab';
const isEscapeKey = event.key === 'Escape';
if (isEscapeKey) {
// Allow Escape to close modal
onClose();
return;
}
if (!isTabKey) return;
const lastElement = focusableElements[focusableElements.length - 1];
const isShiftTab = event.shiftKey;
// Trap Tab at end: move to first element
if (document.activeElement === lastElement && !isShiftTab) {
event.preventDefault();
firstElement.focus();
return;
}
// Trap Shift+Tab at start: move to last element
if (document.activeElement === firstElement && isShiftTab) {
event.preventDefault();
lastElement.focus();
return;
}
};
// 5. Trap focus: prevent Tab from leaving modal
const modal = modalRef.current;
modal.addEventListener('keydown', handleKeyDown);
return () => {
// 6. Cleanup: remove trap and restore initial focus
modal.removeEventListener('keydown', handleKeyDown);
// Restore focus to element that was focused before modal opened
if (initialFocus && document.body.contains(initialFocus)) {
initialFocus.focus();
}
};
}, [isOpen, modalRef, onClose]);
};
// Example usage
export const Modal: React.FC<{
isOpen: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
}> = ({ isOpen, onClose, title, children }) => {
const modalRef = React.useRef<HTMLDivElement>(null);
useFocusTrap(modalRef, onClose, isOpen);
if (!isOpen) return null;
return (
<>
{/* Backdrop: prevent interaction outside modal */}
<div
role="presentation"
onClick={onClose}
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
zIndex: 999,
}}
/>
{/* Modal dialog */}
<div
ref={modalRef}
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
style={{
position: 'fixed',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
backgroundColor: '#fff',
padding: '24px',
borderRadius: '8px',
boxShadow: '0 10px 40px rgba(0, 0, 0, 0.3)',
zIndex: 1000,
maxWidth: '500px',
width: '90%',
maxHeight: '80vh',
overflowY: 'auto',
}}
>
<h2 id="modal-title">{title}</h2>
{children}
{/* Close button - always focusable, allows Escape alternative */}
<button
onClick={onClose}
style={{
marginTop: '16px',
padding: '8px 16px',
backgroundColor: '#f0f0f0',
border: '1px solid #ccc',
borderRadius: '4px',
cursor: 'pointer',
}}
>
Close (or press Escape)
</button>
</div>
</>
);
};
Critical Details:
isOpen={false} (simplifies cleanup)role="dialog" and aria-modal="true"Purpose: Manage focus for composite widgets (tabs, menu, listbox, command palette) where only one child is in tab order (tabindex="0"). Arrow keys move focus between children without leaving widget.
Example: Accessible Tab Component
export const TabsAccessible: React.FC<{
tabs: Array<{ id: string; label: string; content: React.ReactNode }>;
}> = ({ tabs }) => {
const [activeTabId, setActiveTabId] = React.useState(tabs[0].id);
const tabListRef = React.useRef<HTMLDivElement>(null);
const tabRefs = React.useRef<Record<string, HTMLButtonElement>>({});
const handleKeyDown = (event: React.KeyboardEvent<HTMLButtonElement>) => {
const tabIds = tabs.map((t) => t.id);
const currentIndex = tabIds.indexOf(activeTabId);
let nextIndex: number | null = null;
switch (event.key) {
// Arrow Left: move to previous tab (circular)
case 'ArrowLeft': {
event.preventDefault();
nextIndex = (currentIndex - 1 + tabIds.length) % tabIds.length;
break;
}
// Arrow Right: move to next tab (circular)
case 'ArrowRight': {
event.preventDefault();
nextIndex = (currentIndex + 1) % tabIds.length;
break;
}
// Home: jump to first tab
case 'Home': {
event.preventDefault();
nextIndex = 0;
break;
}
// End: jump to last tab
case 'End': {
event.preventDefault();
nextIndex = tabIds.length - 1;
break;
}
default:
return;
}
if (nextIndex !== null) {
const nextTabId = tabIds[nextIndex];
setActiveTabId(nextTabId);
// Focus the new tab button
setTimeout(() => tabRefs.current[nextTabId]?.focus());
}
};
return (
<div>
{/* Tab list: role="tablist" */}
<div
ref={tabListRef}
role="tablist"
style={{
display: 'flex',
borderBottom: '2px solid #e0e0e0',
marginBottom: '16px',
}}
>
{tabs.map((tab, index) => (
<button
key={tab.id}
ref={(el) => {
if (el) tabRefs.current[tab.id] = el;
}}
role="tab"
aria-selected={activeTabId === tab.id}
aria-controls={`panel-${tab.id}`}
// Roving tabindex: only active tab is in tab order
tabIndex={activeTabId === tab.id ? 0 : -1}
onClick={() => setActiveTabId(tab.id)}
onKeyDown={handleKeyDown}
style={{
padding: '12px 16px',
border: 'none',
background: 'transparent',
cursor: 'pointer',
borderBottom: activeTabId === tab.id ? '3px solid #4A90E2' : 'none',
color: activeTabId === tab.id ? '#000' : '#666',
fontWeight: activeTabId === tab.id ? 'bold' : 'normal',
}}
>
{tab.label}
</button>
))}
</div>
{/* Tab panels */}
{tabs.map((tab) => (
<div
key={`panel-${tab.id}`}
role="tabpanel"
id={`panel-${tab.id}`}
aria-labelledby={tab.id}
hidden={activeTabId !== tab.id}
style={{ padding: '16px' }}
>
{tab.content}
</div>
))}
</div>
);
};
// Usage example
export const TabsExample = () => (
<TabsAccessible
tabs={[
{ id: 'tab-1', label: 'Overview', content: <p>Overview content</p> },
{ id: 'tab-2', label: 'Details', content: <p>Details content</p> },
{ id: 'tab-3', label: 'Settings', content: <p>Settings content</p> },
]}
/>
);
Roving TabIndex Rules:
tabindex="0" (in tab order)tabindex="-1" (focusable programmatically, but not by Tab key)role="tablist" (or role="menu", role="listbox" depending on widget type)role="tab", role="menuitem", role="option", etc.aria-selected when focus moves to reflect selected statePurpose: Ensure focused elements are not covered by sticky headers, cookie banners, or other fixed overlays.
Testing: Tab backward through all elements to verify no sticky elements cover focused items.
// Strategy 1: Adjust scroll position when element is focused
export const useFocusNotObscured = () => {
const handleFocus = (event: FocusEvent) => {
const focusedElement = event.target as HTMLElement;
if (!focusedElement) return;
// Get bounding rect of focused element
const rect = focusedElement.getBoundingClientRect();
// Check if element is obscured by viewport-fixed elements (headers, footers)
const stickyHeaderHeight = 60; // Height of sticky header
// If focused element is in the sticky header zone, scroll it down
if (rect.top < stickyHeaderHeight) {
focusedElement.scrollIntoView({ behavior: 'smooth', block: 'start' });
// Add extra padding to account for header
window.scrollBy({ top: -stickyHeaderHeight, behavior: 'smooth' });
}
// Similar check for bottom sticky elements
const stickyFooterHeight = 50;
if (rect.bottom > window.innerHeight - stickyFooterHeight) {
focusedElement.scrollIntoView({ behavior: 'smooth', block: 'end' });
window.scrollBy({ top: stickyFooterHeight, behavior: 'smooth' });
}
};
useEffect(() => {
document.addEventListener('focus', handleFocus, true);
return () => {
document.removeEventListener('focus', handleFocus, true);
};
}, []);
};
// Strategy 2: CSS - ensure sticky header has lower z-index than focused content
const stickyHeaderStyles = `
header.sticky {
position: sticky;
top: 0;
z-index: 100; /* Below focused content */
background: white;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
/* Focused elements should be above sticky header */
*:focus-visible {
z-index: 101;
outline: 3px solid #4A90E2;
outline-offset: 2px;
}
/* Cookie banner: similar treatment */
.cookie-banner {
position: fixed;
bottom: 0;
z-index: 100;
width: 100%;
padding: 16px;
background: #333;
color: white;
}
/* Ensure close button is visible */
.cookie-banner button:focus-visible {
z-index: 101;
outline: 3px solid #FFD700; /* High contrast on dark background */
}
`;
// React component ensuring focus is visible
export const ScrollWithoutObscuring: React.FC<{ children: React.ReactNode }> = ({
children,
}) => {
useFocusNotObscured();
return <div>{children}</div>;
};
Testing for 2.4.11 AA Compliance:
Testing for 2.4.12 AAA Compliance:
<button>, <a>, <form>) before custom divs — they provide keyboard accessibility for free:focus-visible styling to all interactive elements (buttons, links, inputs, custom components)tabindex pattern for composite widgets (tabs, menus, listboxes) — only one child in tab ordertabindex="1" or higher (breaks logical order)prefers-color-scheme: dark media queries)tabindex="1" or any positive tabindex value (breaks natural tab order and confuses keyboard users)outline: none without replacement) — this creates keyboard trapsrole="button" + proper ARIAaria-modal="true" and role="dialog" on modal elementsvisibility: hidden or display: none on focusable elements without removing them from tab order — browser ignores them anywaypointer-events: none on focused elements — it doesn't prevent keyboard focus, creates inconsistencyWhen implementing keyboard navigation and focus management, your output must include:
role attributes, aria-* properties, and tabindex values:focus-visible rules with ≥3px outline, proper contrast ratio, and dark mode supportExample Output Structure:
# Keyboard Navigation Implementation for [Component]
## WCAG Compliance
- ✅ 2.1.1 Keyboard: All functionality via keyboard
- ✅ 2.1.2 No Keyboard Trap: Focus can escape with Escape key
- ✅ 2.4.3 Focus Order: Tab order follows visual layout
- ✅ 2.4.7 Focus Visible: 3px outline, 4.5:1 contrast
- ✅ 2.4.11 Focus Not Obscured (AA): 0px minimum visible
- ✅ 2.4.12 Focus Not Obscured (AAA): 6px minimum visible
## Code
[HTML + CSS + TypeScript]
## Testing
1. Tab through component — verify focus moves left-to-right
2. Shift+Tab backward — verify same elements are reachable
3. Arrow keys (if composite widget) — verify navigation
4. Escape (if modal) — verify close and focus restoration
5. Windows High Contrast Mode — verify outline visible
When generating keyboard navigation code, ensure:
<button>, <a>, not <div role="button"> unless necessary):focus-visible styles applied with ≥3px outline and 3:1 contrast minimumtabindex implemented for composite widgets (tabs, menus): only active child has tabindex="0"#main-content or similararia-selected, aria-expanded, aria-modal, aria-labelledby(prefers-contrast: more) media queryprefers-color-scheme: dark)| Problem | Cause | Solution |
|---|---|---|
| Tab order jumps around (not left-to-right) | Positive tabindex values used | Remove all tabindex except 0 and -1; rely on DOM order |
| Focus disappears on click | :focus styles removed without :focus-visible replacement | Use :focus-visible instead of :focus; always provide outline |
| Modal user can Tab outside | Focus trap not implemented | Use useFocusTrap hook; trap Tab at first/last focusable element |
| Arrow keys don't work in tabs/menu | Roving tabindex pattern not implemented | Set inactive children to tabindex="-1"; add onKeyDown handler for arrow keys |
| Sticky header covers focused element | No scroll handling on focus | Add scrollIntoView() or adjust window.scrollBy() when element focused |
| Focus invisible in High Contrast Mode | Outline color not matching system colors | Use (prefers-contrast: more) media query; test in Windows settings |
| Keyboard user can't see current focus | Focus indicator too subtle | Ensure ≥3px outline, ≥3:1 contrast; use bright colors (e.g., #4A90E2 blue) |