webdev.complete
Accessibility Deep
🚦Going to Production
Lesson 105 of 117
25 min

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.

html
<!-- Good: tab order matches reading order -->
<header>
  <a href="/">Home</a>
  <a href="/about">About</a>
</header>
<main>
  <button>Read more</button>
</main>
Avoid positive tabindex
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).

css
/* 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.

html
<body>
  <a href="#main" class="skip-link">Skip to content</a>
  <header>...</header>
  <nav>...</nav>
  <main id="main" tabindex="-1">
    <!-- page content -->
  </main>
</body>
css
.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.

html
<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.

trap-focus.ts
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
  }
}
Reach for a library
Focus trapping is fiddly. 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:

css
.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). Insert is the modifier. H next heading, F next form field, K next link.
  • TalkBack (Android) and VoiceOver (iOS) for mobile testing.
What "listening" teaches you
Two minutes with VoiceOver on a real product almost always reveals: an icon button announced as just "button," an image with no alt that reads its filename, a modal that doesn't announce itself, a form error that the user never hears.

Quiz

Quiz1 / 4

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-visible shows 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-motion in CSS.
  • Test with VoiceOver or NVDA. You'll learn more in five minutes than a week of reading.
Built with Next.js, Tailwind & Sandpack.
Learn. Build. Ship.