Keyboard and Focus: Building a Website You Can Use Without a Mouse
Focus order, visible focus indicators, skip links, sticky headers, modal dialogs with the native dialog element and composite widgets

Part 4 of 8 in the "Accessible Web in Practice" series. Previous part: Accessible Forms: Labels, Hints, Errors and Autofill.
A quick test: put the mouse aside and try to complete the main task on your site using the Tab key. Most problems show up within five minutes. This part covers how to make keyboard navigation predictable and comfortable.
Keyboard and focus
Focus order
Focus should move in a logical order — usually the same as the visual one. The order is determined by the DOM, so:
- don't rearrange elements visually with
order,flex-direction: row-reverseor absolute positioning in ways that make the tab order diverge from the reading order; - don't use positive
tabindex(tabindex="1","2"...). It breaks the natural order for the entire page.
tabindex has only two legitimate values:
| Value | When |
|---|---|
tabindex="0" |
Make a custom interactive element focusable (when there's truly no native option) |
tabindex="-1" |
Make an element focusable programmatically (element.focus()) but not via Tab: a heading after navigation, an error container, a dialog |
Visible focus
Never do this without a replacement:
/* ❌ Kills keyboard navigation */
*:focus { outline: none; }
If the designer dislikes the default outline, replace it using :focus-visible. This pseudo-class applies during keyboard navigation but not on mouse clicks:
/* ✅ Clear focus for keyboard users only */
:focus-visible {
outline: 3px solid #1a5fb4;
outline-offset: 2px;
}
/* Remove the outline on mouse click where the browser still draws it */
:focus:not(:focus-visible) {
outline: none;
}
A focus indicator needs contrast against the background (at least 3:1) and enough area to notice. If you draw focus with box-shadow, add a transparent outline — it becomes visible in Windows high contrast mode, where shadows are removed:
.button:focus-visible {
box-shadow: 0 0 0 3px #1a5fb4;
outline: 3px solid transparent; /* shows up in forced-colors mode */
}
Skip link
Without one, keyboard users tab through the entire menu on every page.
<a class="skip-link" href="#main">Skip to main content</a>
...
<main id="main" tabindex="-1">...</main>
.skip-link {
position: absolute;
left: 1rem;
top: -100px;
padding: 0.5rem 1rem;
background: #fff;
color: #000;
z-index: 1000;
}
.skip-link:focus {
top: 1rem;
}
The link stays invisible until it receives focus — on the very first Tab.
Focus must not hide (WCAG 2.4.11)
The classic case: a sticky header. The user presses Tab, the page scrolls, and the focused element ends up under the header. One line fixes it:
html {
scroll-padding-top: 5rem; /* sticky header height + some room */
}
The same goes for cookie banners and floating chat widgets: they must not cover the focused element.
Keyboard traps
Focus must never get stuck in an element with no keyboard way out (criterion 2.1.2). Typical culprits: embedded players, map widgets, third-party iframes, custom editors.
The only place where focus should stay inside is a modal dialog. And even there, Esc must close it.
A modal with <dialog>
An accessible modal used to take dozens of lines: a focus trap, hiding the background from screen readers, Esc handling, returning focus. Today the native <dialog> with showModal() gives you all of that:
<button type="button" id="open-dialog">Book a consultation</button>
<dialog id="booking" aria-labelledby="booking-title">
<h2 id="booking-title">Book a consultation</h2>
<form method="dialog">
<label for="b-name">Name</label>
<input id="b-name" name="name" autocomplete="name" required>
<button value="submit">Book</button>
<button value="cancel" formnovalidate>Cancel</button>
</form>
</dialog>
const opener = document.getElementById('open-dialog');
const dialog = document.getElementById('booking');
opener.addEventListener('click', () => dialog.showModal());
// Modern browsers restore focus on their own, but doing it explicitly is safer
dialog.addEventListener('close', () => opener.focus());
showModal() makes the rest of the page inert (unfocusable and hidden from screen readers), moves focus inside, closes on Esc, and ::backdrop dims the background. More on MDN: <dialog>.
For side panels and other cases where part of the page should be temporarily "switched off," there's the inert attribute:
<main inert>...</main> <!-- unfocusable, hidden from screen readers -->
Composite widgets: arrow keys
Tabs, menus, listboxes and trees work differently: Tab enters the widget once, and arrow keys move inside it (the "roving tabindex" technique). If you build such a widget yourself, follow the ARIA Authoring Practices Guide — it has a pattern and the expected keyboard model for each one. Better yet, use a proven library (see Accessibility in React: IDs, Focus on Route Change and Announcements).
The "Accessible Web in Practice" series
- Web Accessibility: Who It Really Serves and Where to Start
- Semantic HTML and Alt Text: The Foundation of an Accessible Website
- Accessible Forms: Labels, Hints, Errors and Autofill
- Keyboard and Focus: Building a Website You Can Use Without a Mouse — you are here
- Contrast, Motion and Button Size: Visual Accessibility
- ARIA and Dynamic Content: When Attributes Help and When They Hurt
- Accessibility in React: IDs, Focus on Route Change and Announcements
- How to Test Accessibility: Automation, Manual Checks and a Checklist
Next part: Contrast, Motion and Button Size: Visual Accessibility
Agama Labs builds websites and web applications. Want to check how accessible your site is? Write to us: hello@agamalabs.com


