Tab Order
Tab Order
Control the order keyboard users navigate through elements.
Natural Tab Order
<!-- Tab order follows DOM order -->
<button>First</button>
<input type="text" />
<button>Third</button>
Tab Index
<!-- tabindex="0": Add to tab order -->
<div tabindex="0" role="button">Custom Button</div>
<!-- tabindex="-1": Focusable but not in tab order -->
<div tabindex="-1" id="modal">Modal Content</div>
<!-- tabindex="1+": Avoid (forces tab order) -->
<!-- Don't use positive tabindex values -->
Semantic Elements
// Use native elements for built-in tab behavior
function Form() {
return (
<form>
<input type="text" /> {/* Tabbable */}
<select>{/* Tabbable */}</select>
<button type="submit">Submit</button> {/* Tabbable */}
<a href="/">Link</a> {/* Tabbable */}
</form>
);
}
// Custom elements need tabindex
function CustomButton({ onClick, children }) {
return (
<div
role="button"
tabIndex={0}
onClick={onClick}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onClick();
}
}}
>
{children}
</div>
);
}
Roving Tab Index
// For composite widgets like menus
function Menu({ items }) {
const [activeIndex, setActiveIndex] = useState(0);
return (
<ul role="menu">
{items.map((item, index) => (
<li
key={item.id}
role="menuitem"
tabIndex={index === activeIndex ? 0 : -1}
onKeyDown={(e) => handleKeyDown(e, index)}
>
{item.label}
</li>
))}
</ul>
);
}
Hidden Content
// Remove from tab order when hidden
function Modal({ isOpen, children }) {
return isOpen ? (
<div role="dialog" aria-modal="true">
{children}
</div>
) : null;
}
// Or use inert attribute
<div inert={isOpen ? false : true}>
<button>Focusable</button>
</div>
Keyboard Shortcuts
Keyboard Shortcuts
Implement keyboard shortcuts for power users.
Basic Shortcuts
function useKeyboardShortcuts(shortcuts) {
useEffect(() => {
const handleKeyDown = (e) => {
// Skip if inside input/textarea
if (['INPUT', 'TEXTAREA'].includes(e.target.tagName)) return;
const key = [
e.ctrlKey && 'ctrl',
e.shiftKey && 'shift',
e.altKey && 'alt',
e.key.toLowerCase(),
].filter(Boolean).join('+');
if (shortcuts[key]) {
e.preventDefault();
shortcuts[key]();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [shortcuts]);
}
// Usage
function App() {
useKeyboardShortcuts({
'ctrl+s': () => saveDocument(),
'ctrl+z': () => undo(),
'ctrl+shift+z': () => redo(),
'ctrl+p': () => printDocument(),
});
return <Editor />;
}
Command Palette
function CommandPalette({ isOpen, onClose }) {
const [query, setQuery] = useState('');
const [selectedIndex, setSelectedIndex] = useState(0);
const commands = [
{ id: 'save', label: 'Save', shortcut: 'Ctrl+S', action: save },
{ id: 'open', label: 'Open', shortcut: 'Ctrl+O', action: open },
{ id: 'delete', label: 'Delete', shortcut: 'Del', action: deleteItem },
];
const filtered = commands.filter(cmd =>
cmd.label.toLowerCase().includes(query.toLowerCase())
);
useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (e) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
setSelectedIndex(prev => Math.min(prev + 1, filtered.length - 1));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setSelectedIndex(prev => Math.max(prev - 1, 0));
} else if (e.key === 'Enter') {
e.preventDefault();
filtered[selectedIndex]?.action();
onClose();
} else if (e.key === 'Escape') {
onClose();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [isOpen, filtered, selectedIndex, onClose]);
if (!isOpen) return null;
return (
<div className="command-palette" role="dialog" aria-label="Command palette">
<input
type="text"
value={query}
onChange={(e) => {
setQuery(e.target.value);
setSelectedIndex(0);
}}
placeholder="Type a command..."
autoFocus
/>
<ul role="listbox">
{filtered.map((cmd, index) => (
<li
key={cmd.id}
role="option"
aria-selected={index === selectedIndex}
className={index === selectedIndex ? 'selected' : ''}
>
<span>{cmd.label}</span>
<kbd>{cmd.shortcut}</kbd>
</li>
))}
</ul>
</div>
);
}
Shortcut Documentation
function ShortcutHelp() {
const shortcuts = [
{ keys: ['Ctrl', 'S'], action: 'Save' },
{ keys: ['Ctrl', 'Z'], action: 'Undo' },
{ keys: ['Ctrl', 'Shift', 'Z'], action: 'Redo' },
{ keys: ['Ctrl', '/'], action: 'Toggle comments' },
];
return (
<div className="shortcut-help">
<h2>Keyboard Shortcuts</h2>
<dl>
{shortcuts.map((shortcut, i) => (
<div key={i}>
<dt>
{shortcut.keys.map(key => (
<kbd key={key}>{key}</kbd>
)).reduce((prev, curr) => [prev, ' + ', curr])}
</dt>
<dd>{shortcut.action}</dd>
</div>
))}
</dl>
</div>
);
}
Custom Key Handlers
Custom Key Handlers
Handle keyboard interactions for custom components.
Custom Hook
// hooks/useKeyboard.js
function useKeyboard(handlers) {
useEffect(() => {
const handleKeyDown = (e) => {
for (const [key, handler] of Object.entries(handlers)) {
if (matchKey(e, key)) {
handler(e);
break;
}
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [handlers]);
}
function matchKey(event, key) {
const parts = key.toLowerCase().split('+');
const modifiers = parts.slice(0, -1);
const keyName = parts[parts.length - 1];
if (modifiers.includes('ctrl') && !event.ctrlKey) return false;
if (modifiers.includes('shift') && !event.shiftKey) return false;
if (modifiers.includes('alt') && !event.altKey) return false;
return event.key.toLowerCase() === keyName;
}
Accessible List
function AccessibleList({ items, onSelect }) {
const [activeIndex, setActiveIndex] = useState(0);
const handleKeyDown = (e) => {
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
setActiveIndex(prev => Math.min(prev + 1, items.length - 1));
break;
case 'ArrowUp':
e.preventDefault();
setActiveIndex(prev => Math.max(prev - 1, 0));
break;
case 'Home':
e.preventDefault();
setActiveIndex(0);
break;
case 'End':
e.preventDefault();
setActiveIndex(items.length - 1);
break;
case 'Enter':
case ' ':
e.preventDefault();
onSelect(items[activeIndex]);
break;
}
};
return (
<ul
role="listbox"
tabIndex={0}
onKeyDown={handleKeyDown}
>
{items.map((item, index) => (
<li
key={item.id}
role="option"
aria-selected={index === activeIndex}
ref={index === activeIndex ? activeRef : null}
>
{item.label}
</li>
))}
</ul>
);
}
Grid Navigation
function DataGrid({ rows, columns }) {
const [activeCell, setActiveCell] = useState({ row: 0, col: 0 });
const handleKeyDown = (e) => {
const { row, col } = activeCell;
switch (e.key) {
case 'ArrowRight':
e.preventDefault();
setActiveCell({ row, col: Math.min(col + 1, columns.length - 1) });
break;
case 'ArrowLeft':
e.preventDefault();
setActiveCell({ row, col: Math.max(col - 1, 0) });
break;
case 'ArrowDown':
e.preventDefault();
setActiveCell({ row: Math.min(row + 1, rows.length - 1), col });
break;
case 'ArrowUp':
e.preventDefault();
setActiveCell({ row: Math.max(row - 1, 0), col });
break;
}
};
return (
<table
role="grid"
tabIndex={0}
onKeyDown={handleKeyDown}
>
<thead>
<tr>
{columns.map(col => (
<th key={col.id} scope="col">{col.label}</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row, rowIndex) => (
<tr key={row.id}>
{columns.map((col, colIndex) => (
<td
key={col.id}
tabIndex={rowIndex === activeCell.row && colIndex === activeCell.col ? 0 : -1}
aria-selected={rowIndex === activeCell.row && colIndex === activeCell.col}
>
{row[col.id]}
</td>
))}
</tr>
))}
</tbody>
</table>
);
}
Practice Problems
Create a reusable React component implementing Keyboard Navigation. Include proper state management and accessibility.
Solution
// Production-ready component with:
// - Proper TypeScript types
// - Accessibility (ARIA)
// - Error boundaries
// - Loading states
// - Memoization where neededWrite unit and integration tests for Keyboard Navigation using React Testing Library.
Solution
// Test coverage:
// 1. Rendering tests
// 2. Interaction tests
// 3. Edge case tests
// 4. Accessibility testsOptimize Keyboard Navigation for performance. Consider memoization, code splitting, and bundle size.
Solution
// Optimization techniques:
// 1. React.memo / useMemo / useCallback
// 2. Code splitting with lazy()
// 3. Virtual scrolling for lists
// 4. Image lazy loading
// 5. Bundle analysisQuiz
1. What does tabindex="0" do?
2. What is roving tabindex?
3. What is the primary purpose of Keyboard Navigation?
4. What is a common mistake when implementing Keyboard Navigation?
Flashcards
Question
What is tab order?
Click to reveal answer
Answer
The order keyboard users navigate through focusable elements on the page.
Question
What does tabindex="0" do?
Click to reveal answer
Answer
Adds an element to the natural tab order.
Question
What is roving tabindex?
Click to reveal answer
Answer
A technique for composite widgets where only the active item has tabindex="0".
Question
Why skip inputs in keyboard shortcuts?
Click to reveal answer
Answer
To avoid interfering with normal typing in form fields.
Question
What is Keyboard Navigation?
Click to reveal answer
Answer
Keyboard Navigation is a key concept in frontend development.
Revision Notes
Key Takeaways
- 1.Use tabindex="0" to add custom elements to tab order
- 2.Roving tabindex is best for composite widgets
- 3.Always test keyboard navigation
- 4.Skip form inputs when handling global shortcuts
- 5.Document keyboard shortcuts for users
Interview Tips
- •Explain tabindex values and their effects
- •Discuss how to implement keyboard navigation for custom components
- •Know how to handle keyboard shortcuts properly
Cheat Sheet
Keyboard Navigation Cheat Sheet
Tab Index
- tabindex="0": Add to tab order
- tabindex="-1": Focusable, not in tab order
- Avoid positive values
Keyboard Shortcuts
- Check for modifier keys
- Skip inputs/textareas
- Use preventDefault()
Custom Handlers
- Arrow keys for navigation
- Enter/Space for activation
- Escape for closing
- Home/End for jumping