Keyboard navigation ensures that all functionality on your website can be accessed and operated using only a keyboard. This is essential for users with motor disabilities, screen reader users, power users, and anyone who prefers keyboard shortcuts over a mouse.
Why Keyboard Navigation Matters
Many users rely exclusively on keyboard navigation:
- Screen reader users: Navigate primarily with keyboard
- Motor disabilities: May not be able to use a mouse
- Temporary injuries: Broken arm, wrist pain
- Power users: Prefer keyboard for efficiency
- Assistive technology users: Switch controls, voice commands
If your site isn't keyboard accessible, you're excluding a significant portion of your audience.
Essential Keyboard Keys
Users navigate websites using these primary keys:
- Tab: Move forward through interactive elements
- Shift + Tab: Move backward through interactive elements
- Enter/Return: Activate links and buttons
- Space: Activate buttons, check checkboxes, scroll page
- Arrow keys: Navigate within components (menus, sliders, tabs)
- Escape: Close dialogs, menus, or cancel actions
- Home/End: Jump to beginning/end of content
Focus Management
Focus is the visual indicator showing which element is currently active for keyboard interaction. Proper focus management is crucial for keyboard accessibility.
Visible Focus Indicators
Always ensure users can see where they are on the page:
/* Good: Visible, high-contrast focus indicator */
a:focus,
button:focus,
input:focus {
outline: 3px solid #0066cc;
outline-offset: 2px;
}
/* Better: Custom focus style that matches your design */
button:focus {
outline: none; /* Only remove if you replace it! */
box-shadow: 0 0 0 3px rgba(0, 102, 204, 0.5);
border-color: #0066cc;
}
/* Best: Use :focus-visible for mouse vs keyboard */
button:focus {
outline: none;
}
button:focus-visible {
outline: 3px solid #0066cc;
outline-offset: 2px;
}
/* NEVER do this without replacement */
*:focus {
outline: none; /* This removes all focus indicators! */
}Naturally Focusable Elements
These HTML elements are focusable by default:
<!-- Naturally keyboard accessible -->
<a href="/page">Link</a>
<button>Button</button>
<input type="text">
<textarea></textarea>
<select>
<option>Option 1</option>
</select>
<!-- NOT naturally focusable -->
<div>Not focusable</div>
<span>Not focusable</span>
<p>Not focusable</p>The tabindex Attribute
The tabindex attribute controls whether an element can receive focus and its position in the tab order:
<!-- tabindex="0" - Add to tab order in natural position -->
<div tabindex="0" role="button" onclick="handleClick()">
Custom Button
</div>
<!-- tabindex="-1" - Programmatically focusable, not in tab order -->
<div tabindex="-1" id="modal-content">
This can receive focus via JavaScript
</div>
<!-- tabindex="1" or higher - DON'T USE THIS -->
<!-- Creates confusing tab order -->
<input tabindex="1" type="text">
<button tabindex="2">Submit</button>Important:
Never use positive tabindex values (1, 2, 3, etc.). They override the natural tab order and create a confusing experience. Only use tabindex="0" or tabindex="-1".
Keyboard Event Handlers
When creating custom interactive elements, handle keyboard events properly:
<div class="custom-button"
tabindex="0"
role="button"
id="myButton">
Click me
</div>
<script>
const btn = document.getElementById('myButton');
// Handle keyboard events
btn.addEventListener('keydown', function(event) {
// Enter or Space should activate the button
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault(); // Prevent space from scrolling
this.click();
}
});
// Handle click for mouse users
btn.addEventListener('click', function() {
console.log('Button activated!');
// Perform action
});
</script>Skip Links
Skip links allow keyboard users to bypass repetitive content like navigation menus:
<!DOCTYPE html>
<html lang="en">
<head>
<style>
/* Hide skip link until focused */
.skip-link {
position: absolute;
top: -40px;
left: 0;
background: #000;
color: #fff;
padding: 8px;
text-decoration: none;
z-index: 100;
}
.skip-link:focus {
top: 0;
}
</style>
</head>
<body>
<!-- Skip link is the first focusable element -->
<a href="#main-content" class="skip-link">
Skip to main content
</a>
<header>
<nav>
<!-- 20+ navigation links here -->
</nav>
</header>
<main id="main-content" tabindex="-1">
<h1>Page Content</h1>
<p>Main content starts here...</p>
</main>
</body>
</html>Focus Trapping in Modals
When a modal opens, trap focus inside it so users can't accidentally tab outside:
<button id="openModal">Open Modal</button>
<div id="modal"
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
hidden>
<h2 id="modal-title">Modal Title</h2>
<p>Modal content</p>
<button id="confirmBtn">Confirm</button>
<button id="closeBtn">Close</button>
</div>
<script>
const modal = document.getElementById('modal');
const openBtn = document.getElementById('openModal');
const closeBtn = document.getElementById('closeBtn');
let previouslyFocused;
function openModal() {
// Save current focus
previouslyFocused = document.activeElement;
// Show modal
modal.hidden = false;
// Move focus to modal
modal.querySelector('button').focus();
// Trap focus
trapFocus(modal);
// Listen for Escape key
document.addEventListener('keydown', handleEscape);
}
function closeModal() {
modal.hidden = true;
document.removeEventListener('keydown', handleEscape);
// Return focus to trigger button
if (previouslyFocused) {
previouslyFocused.focus();
}
}
function handleEscape(e) {
if (e.key === 'Escape') {
closeModal();
}
}
function trapFocus(element) {
const focusableElements = element.querySelectorAll(
'a[href], button:not([disabled]), textarea, input, select'
);
const firstFocusable = focusableElements[0];
const lastFocusable = focusableElements[focusableElements.length - 1];
element.addEventListener('keydown', function(e) {
if (e.key === 'Tab') {
if (e.shiftKey) {
// Shift + Tab
if (document.activeElement === firstFocusable) {
e.preventDefault();
lastFocusable.focus();
}
} else {
// Tab
if (document.activeElement === lastFocusable) {
e.preventDefault();
firstFocusable.focus();
}
}
}
});
}
openBtn.addEventListener('click', openModal);
closeBtn.addEventListener('click', closeModal);
</script>Common Keyboard Patterns
Tab Component
<div class="tabs">
<div role="tablist" aria-label="Sample Tabs">
<button role="tab"
aria-selected="true"
aria-controls="panel1"
id="tab1">
Tab 1
</button>
<button role="tab"
aria-selected="false"
aria-controls="panel2"
id="tab2">
Tab 2
</button>
<button role="tab"
aria-selected="false"
aria-controls="panel3"
id="tab3">
Tab 3
</button>
</div>
<div role="tabpanel" id="panel1" aria-labelledby="tab1">
<p>Content for Tab 1</p>
</div>
<div role="tabpanel" id="panel2" aria-labelledby="tab2" hidden>
<p>Content for Tab 2</p>
</div>
<div role="tabpanel" id="panel3" aria-labelledby="tab3" hidden>
<p>Content for Tab 3</p>
</div>
</div>
<script>
const tabs = document.querySelectorAll('[role="tab"]');
const panels = document.querySelectorAll('[role="tabpanel"]');
tabs.forEach((tab, index) => {
// Arrow key navigation
tab.addEventListener('keydown', (e) => {
let newIndex;
if (e.key === 'ArrowRight') {
newIndex = (index + 1) % tabs.length;
tabs[newIndex].focus();
activateTab(newIndex);
} else if (e.key === 'ArrowLeft') {
newIndex = (index - 1 + tabs.length) % tabs.length;
tabs[newIndex].focus();
activateTab(newIndex);
} else if (e.key === 'Home') {
tabs[0].focus();
activateTab(0);
} else if (e.key === 'End') {
tabs[tabs.length - 1].focus();
activateTab(tabs.length - 1);
}
});
// Click activation
tab.addEventListener('click', () => {
activateTab(index);
});
});
function activateTab(index) {
tabs.forEach((t, i) => {
if (i === index) {
t.setAttribute('aria-selected', 'true');
panels[i].hidden = false;
} else {
t.setAttribute('aria-selected', 'false');
panels[i].hidden = true;
}
});
}
</script>Dropdown Menu
<div class="dropdown">
<button aria-haspopup="true"
aria-expanded="false"
aria-controls="menu1"
id="menuButton">
Menu
</button>
<ul role="menu" id="menu1" hidden>
<li role="menuitem">
<a href="#option1">Option 1</a>
</li>
<li role="menuitem">
<a href="#option2">Option 2</a>
</li>
<li role="menuitem">
<a href="#option3">Option 3</a>
</li>
</ul>
</div>
<script>
const button = document.getElementById('menuButton');
const menu = document.getElementById('menu1');
const items = menu.querySelectorAll('[role="menuitem"]');
let currentIndex = -1;
button.addEventListener('click', toggleMenu);
button.addEventListener('keydown', (e) => {
if (e.key === 'ArrowDown' || e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
openMenu();
items[0].focus();
}
});
function toggleMenu() {
const expanded = button.getAttribute('aria-expanded') === 'true';
if (expanded) {
closeMenu();
} else {
openMenu();
}
}
function openMenu() {
button.setAttribute('aria-expanded', 'true');
menu.hidden = false;
}
function closeMenu() {
button.setAttribute('aria-expanded', 'false');
menu.hidden = true;
button.focus();
}
// Arrow key navigation in menu
items.forEach((item, index) => {
item.addEventListener('keydown', (e) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
const next = (index + 1) % items.length;
items[next].focus();
} else if (e.key === 'ArrowUp') {
e.preventDefault();
const prev = (index - 1 + items.length) % items.length;
items[prev].focus();
} else if (e.key === 'Escape') {
closeMenu();
}
});
});
</script>Roving Tabindex Pattern
For groups of similar elements (like toolbars or radio groups), use roving tabindex to allow only one item to be in tab order:
<div role="toolbar" aria-label="Text formatting">
<button tabindex="0">Bold</button>
<button tabindex="-1">Italic</button>
<button tabindex="-1">Underline</button>
<button tabindex="-1">Strikethrough</button>
</div>
<script>
const toolbar = document.querySelector('[role="toolbar"]');
const buttons = toolbar.querySelectorAll('button');
buttons.forEach((button, index) => {
button.addEventListener('keydown', (e) => {
let newIndex;
if (e.key === 'ArrowRight') {
newIndex = (index + 1) % buttons.length;
} else if (e.key === 'ArrowLeft') {
newIndex = (index - 1 + buttons.length) % buttons.length;
} else {
return;
}
// Move tabindex
buttons.forEach(btn => btn.tabIndex = -1);
buttons[newIndex].tabIndex = 0;
buttons[newIndex].focus();
});
});
</script>Testing Keyboard Accessibility
To test keyboard accessibility:
- Unplug your mouse and navigate using only keyboard
- Press Tab to move through interactive elements
- Check focus indicators are visible at all times
- Verify all functionality works with keyboard
- Test custom widgets with appropriate keys (arrows, Enter, Space, Escape)
- Check tab order is logical and predictable
- Ensure no keyboard traps where users get stuck
Keyboard Navigation Best Practices
- Never remove focus indicators without providing visible alternatives
- Keep tab order logical: Follow visual order of page
- Avoid positive tabindex values: Use 0 or -1 only
- Provide skip links: Let users bypass repetitive content
- Trap focus in modals: Keep users from tabbing outside
- Return focus appropriately: When closing dialogs or deleting items
- Support standard keys: Enter, Space, Escape, Arrows
- Use semantic HTML: Get keyboard support for free
- Test regularly: Try navigating without a mouse
Key Takeaways
- All functionality must be accessible via keyboard alone
- Always provide visible focus indicators for keyboard users
- Use
tabindex="0"to add elements to tab order,tabindex="-1"for programmatic focus - Never use positive tabindex values
- Implement skip links to bypass repetitive content
- Trap focus within modal dialogs
- Handle keyboard events (Enter, Space, Escape, Arrows) for custom widgets
- Test your site by navigating without a mouse
What's Next?
Now that you understand keyboard navigation, you can ensure your website is accessible to all users. In the next lesson, we'll explore alt text best practices for images, learning how to write effective descriptions for screen reader users.