Keyboard, Focus, Screen Readers
Focus trap, focus restore. VoiceOver, NVDA basics.
Put your mouse away for the next ten minutes and try to use your own site. If you get stuck, congratulations: you just found the bugs your keyboard-only users have been hitting for months. Keyboard accessibility is the part of a11y that's easiest to feel, easiest to fix, and most frequently shipped broken. Let's cover the patterns that fix 90% of it.
Tab order: just use the DOM
When a user presses Tab, the browser advances focus through every focusable element in DOM order. Focusable means: links with href, buttons, form fields, and anything with a non-negative tabindex.
<!-- Good: tab order matches reading order -->
<header>
<a href="/">Home</a>
<a href="/about">About</a>
</header>
<main>
<button>Read more</button>
</main>tabindex="5" jumps an element to position 5 in focus order, ignoring DOM order. This always breaks something for someone. The only values you should use are 0 (focusable, normal order) and -1 (focusable only by JS, not by Tab).:focus vs :focus-visible
Browsers have always shown a focus ring when you tab to an element. For years, developers hated that ring on mouse clicks and wrote outline: none. Then keyboard users couldn't tell where focus was. The web learned. The modern answer is :focus-visible, which only fires when the browser thinks the focus came from the keyboard (or another non-pointer source).
/* DON'T do this - kills keyboard usability */
button:focus { outline: none; }
/* DO this - hide the ring on click, show it on tab */
button:focus { outline: none; }
button:focus-visible {
outline: 2px solid #2563eb;
outline-offset: 2px;
}Or simpler: just don't remove the default ring in the first place. The browser already does the right thing.
Skip links
Every page has the same nav. Keyboard users have to tab through it every single time. A skip link is the first focusable element on the page, normally hidden, that jumps focus to the main content.
<body>
<a href="#main" class="skip-link">Skip to content</a>
<header>...</header>
<nav>...</nav>
<main id="main" tabindex="-1">
<!-- page content -->
</main>
</body>.skip-link {
position: absolute;
top: -40px;
left: 0;
background: #000;
color: #fff;
padding: 8px 12px;
z-index: 100;
}
/* Slide into view when keyboard-focused */
.skip-link:focus { top: 0; }The link target needs tabindex="-1" on <main> so that activating the skip link actually moves focus (not just the scroll position) onto it.
Modal dialogs: trap focus, then return it
When a modal opens, focus should move inside it. Tab and Shift+Tabshould cycle within the modal, never escape to the page behind. When the modal closes, focus should return to the element that opened it (so the user doesn't lose their place).
The good news: modern browsers ship this for free with the native <dialog> element and its showModal() method.
<button id="open">Delete project</button>
<dialog id="confirm">
<h2>Delete project?</h2>
<p>This cannot be undone.</p>
<form method="dialog">
<button value="cancel">Cancel</button>
<button value="confirm">Delete</button>
</form>
</dialog>
<script>
const dlg = document.getElementById("confirm");
document.getElementById("open").addEventListener("click", () => {
dlg.showModal(); // focus moves into the dialog, Tab is trapped
});
dlg.addEventListener("close", () => {
// Focus auto-returns to the opener
});
</script>If you can't use <dialog> (legacy app, custom styling fights with it), the manual version: query all focusable elements inside the modal, wrap focus from last to first and back, listen for Escape, store the opener so you can restore focus on close.
function trapFocus(modal: HTMLElement, opener: HTMLElement) {
const focusables = modal.querySelectorAll<HTMLElement>(
'a[href], button, textarea, input, select, [tabindex]:not([tabindex="-1"])'
);
const first = focusables[0];
const last = focusables[focusables.length - 1];
first?.focus();
modal.addEventListener("keydown", (e) => {
if (e.key === "Escape") close();
if (e.key !== "Tab") return;
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last?.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first?.focus();
}
});
function close() {
modal.hidden = true;
opener.focus(); // critical: restore focus to where it came from
}
}focus-trap-react, @radix-ui/react-dialog, and @headlessui/react all do this correctly and handle every weird edge case. Use them.prefers-reduced-motion
Vestibular disorders, migraines, and certain forms of attention sensitivity make animations actively painful or disorienting. Modern OSes let users opt out, and CSS exposes that choice via a media query:
.card { transition: transform 300ms ease; }
.card:hover { transform: translateY(-4px); }
@media (prefers-reduced-motion: reduce) {
.card { transition: none; }
.card:hover { transform: none; }
}Toggle the setting yourself: on macOS, System Settings → Accessibility → Display → Reduce motion. Then reload your site and see what breaks.
Screen reader testing
ARIA is invisible. The only way to know what your page actually sounds like is to listen.
- VoiceOver (macOS, free). Toggle: Cmd+F5. Get to know the "VO" key (Ctrl+Option). VO+arrow keys to navigate, VO+U for the rotor (jump by heading, link, form control).
- NVDA (Windows, free).
Insertis the modifier.Hnext heading,Fnext form field,Knext link. - TalkBack (Android) and VoiceOver (iOS) for mobile testing.
Quiz
What's the right way to hide the focus ring on a mouse click while keeping it for keyboard users?
Recap
- Tab order = DOM order. Never use positive
tabindex. - Keep focus rings.
:focus-visibleshows them only when they matter. - Add a skip link as the first element on every page.
- For modals: use
<dialog>when you can, otherwise trap focus and restore it to the opener on close. - Respect
prefers-reduced-motionin CSS. - Test with VoiceOver or NVDA. You'll learn more in five minutes than a week of reading.