Skip to content
inklu
keyboard-navigationfocus-managementwcagdeveloper-workflowsaccessibility

Keyboard navigation: the accessibility work your mouse hides from you

Most keyboard accessibility bugs are invisible until you put the mouse down. Here are the focus patterns we look for in PR review and the code that fixes them.

By Moe· Co-founder, Design9 min read

Unplug your mouse. Not metaphorically — actually reach down and pull it out, or just promise yourself you will not touch the trackpad. Now try to use the app you shipped last week. Open the nav menu. Close the modal. Get through the dropdown filter and back to the results. Most teams have never done this, which is exactly why keyboard bugs survive all the way to production. They are invisible to the person building the feature, because that person is holding a mouse the entire time.

A keyboard user — someone with a motor disability, a screen reader user, a power user who never left the home row — experiences a different application than the one you tested. Buttons that are actually <div>s do nothing when they press Enter. A modal opens but Tab keeps moving through the page behind it. They open a custom dropdown and the focus ring vanishes into a void. None of this shows up in a visual review, and a surprising amount of it slips past automated scanning too, because focus order and focus movement are runtime behaviors, not static markup you can lint in isolation.

This post is about the keyboard patterns I look for when I review a PR, what the failure looks like, and the code that fixes it.

The four failures I can predict before opening the diff

After enough reviews you stop being surprised. Keyboard accessibility bugs cluster around four issues:

  1. Interactive elements that are not really interactive
  2. Focus that is invisible because someone removed the outline
  3. Modals and menus that do not trap or restore focus
  4. No way to skip past the same fifty links on every page

Three of these map directly to WCAG 2.2 success criteria — 2.1.1 Keyboard, 2.4.3 Focus Order, 2.4.7 Focus Visible, and the newer 2.4.11 Focus Not Obscured. The fixes are rarely large. They get skipped because nobody on the team navigates with a keyboard, so nobody feels the pain.

Clickable things that ignore the keyboard

The most common one. A designer wants a clickable card or a custom button, and the markup comes out like this:

<div className="button" onClick={handleSave}>
  Save changes
</div>

A mouse user clicks it and it works. A keyboard user cannot reach it at all — a <div> is not in the tab order — and even if they could, pressing Enter or Space would do nothing, because a <div> has no default activation behavior. This violates WCAG 2.1.1 (Keyboard) outright.

The fix is almost always to use the element the browser already built for this:

<button type="button" onClick={handleSave}>
  Save changes
</button>

A real <button> is focusable, sits in the tab order in the right place, fires its handler on both Enter and Space, and announces itself as a button to assistive tech. You get all of that for free. The only reason to reach for a <div> is styling, and a button can be styled to look like anything — there is no visual effect you can achieve on a div that you cannot achieve on a button.

If you genuinely cannot use a native element — say you are wrapping a third-party component you do not control — you have to rebuild what the browser gave away: add role="button", put it in the tab order with tabIndex={0}, and handle both keys.

<div
  role="button"
  tabIndex={0}
  onClick={handleSave}
  onKeyDown={(e) => {
    if (e.key === "Enter" || e.key === " ") {
      e.preventDefault();
      handleSave();
    }
  }}
>
  Save changes
</div>

That is a lot of code to reproduce something <button> does on its own. Whenever you see this pattern in a diff, the right question is not "is the ARIA correct" but "why is this not a button."

The focus ring you deleted

Somewhere in almost every codebase is a line like this:

*:focus {
  outline: none;
}

It usually got added because a designer did not like the default focus ring, or because a "CSS reset" tutorial included it. It means a keyboard user pressing Tab has no idea where they are on the page. Focus is moving, but invisibly. This is a direct WCAG 2.4.7 (Focus Visible) failure, and it is one of the most damaging single lines you can ship, because it breaks every interactive element at once.

You are allowed to replace the default ring with something that fits your design. You are not allowed to remove it and put nothing back. The modern approach uses :focus-visible, which shows the ring for keyboard users and suppresses it for mouse clicks — which is what most people actually wanted when they reached for outline: none:

:focus-visible {
  outline: 2px solid var(--focus-color);
  outline-offset: 2px;
}

/* Only suppress the ring when focus came from a pointer, never globally */
:focus:not(:focus-visible) {
  outline: none;
}

Whatever you use, the focus indicator needs enough contrast against its background to actually be seen — WCAG 2.2 added 2.4.11 (Focus Not Obscured) on top of this, which means a sticky header or a cookie banner must not cover the focused element either. If your focus ring is there but a fixed footer is sitting on top of the element that has it, you have solved the wrong half of the problem. This connects to the same contrast discipline that matters everywhere else in your UI — we wrote about getting those tokens right in color contrast in design systems.

Modals that leak focus

A modal opens. Visually it is the only thing on screen — there is a dark overlay behind it. But press Tab a few times and watch the focus ring walk right off the modal and into the page underneath, tabbing through links the user cannot even see. Then they press Escape and nothing happens, because nobody wired it up.

A correct dialog does four things, and most hand-rolled modals do one or two:

  • Moves focus into the dialog when it opens
  • Keeps focus inside the dialog while it is open (a focus trap)
  • Returns focus to the element that opened it when it closes
  • Closes on Escape

This is enough fiddly logic — and easy enough to get subtly wrong — that I almost always recommend not writing it by hand. The native <dialog> element handles the trap and Escape for you when opened with showModal():

function ConfirmDialog({ open, onClose }) {
  const ref = useRef(null);

  useEffect(() => {
    const node = ref.current;
    if (open) node?.showModal();
    else node?.close();
  }, [open]);

  return (
    <dialog ref={ref} onClose={onClose}>
      <h2>Delete this project?</h2>
      <p>This cannot be undone.</p>
      <button onClick={onClose}>Cancel</button>
      <button onClick={handleDelete}>Delete</button>
    </dialog>
  );
}

If you need more control than <dialog> gives you, reach for a vetted primitive — the dialog components in libraries like Radix or React Aria implement the full focus contract and have been tested with real assistive tech. The thing not to do is build the seventh slightly-broken modal in your codebase from scratch. Whatever you choose, set focus to a sensible element inside the dialog on open (the heading or the first input, not the destructive button) and restore focus to the trigger on close so the keyboard user does not get dumped back at the top of the page.

Every page on your site probably starts with the same header: a logo, a primary nav with eight items, maybe a search box, maybe a notifications bell. A keyboard user who wants to reach the actual content has to Tab through all of it. On every single page. Every single time.

A skip link is the fix, and it has been the fix for twenty years. It is a link at the very top of the tab order that jumps focus straight to the main content:

<a href="#main" className="skip-link">
  Skip to main content
</a>
{/* ...header, nav... */}
<main id="main" tabIndex={-1}>
  {/* page content */}
</main>

The convention is to hide the link visually until it receives focus, so it does not clutter the design but appears the instant a keyboard user Tabs to it:

.skip-link {
  position: absolute;
  left: -9999px;
}
.skip-link:focus {
  left: 1rem;
  top: 1rem;
}

The tabIndex={-1} on <main> is what lets focus actually land there when the link is followed — without it some browsers move the viewport but not the focus, and the user is no better off. This is a ten-minute change that every keyboard user on your site feels on every page.

Why automated scanning only gets you partway here

I want to be straight about what a scanner can and cannot do, because keyboard accessibility is where the limits of automation are most honest. axe-core, the engine inklu builds on, will reliably catch the static markup failures: an element with a click handler and no keyboard role, a missing skip link target, an outline: none with nothing to replace it, an interactive control that is not focusable. Our proprietary rules layer on top of axe-core picks up a number of the framework-specific versions — onClick on a <div> in JSX, dialogs missing their focus contract — and the auto-fix PR will rewrite the obvious ones: swap the div for a button, add the skip link, restore a :focus-visible style.

What no static tool can fully judge is the runtime experience — whether focus order actually matches the visual order after your layout reflows, whether the focus trap holds when a dropdown inside the modal opens, whether focus lands somewhere sensible after an async action. Those are behaviors, and behaviors have to be walked. The good news is that the walk is fast: unplug the mouse, Tab from the top of the page to the bottom, open and close every overlay, and watch where the focus ring goes. For a typical screen that is two minutes. If those two minutes surprise you, they would have stopped a real user cold.

What to do next

If you are building something new, the rule of thumb that prevents most of this: use the native element (button, a, dialog, input), never delete a focus ring without replacing it, and Tab through the feature before you open the PR. That is most keyboard accessibility for free.

If you are auditing an existing app, run a scan to surface the static failures first — unlabeled custom controls, missing skip links, removed focus styles — then do a keyboard pass on your highest-traffic flows. A repo scan on a typical React app finishes in a few minutes and the fixes come back as pull requests with the markup already rewritten, which means you get to spend your time on the runtime walk that no tool can do for you. If you are weighing this against a DevTools-style manual checker, the difference is in that fix delivery step — there is a side-by-side on the inklu vs axe DevTools page.

Want to see what it finds on your codebase? Book a demo at inklu.io or email hello@inklu.io. Pricing is on the pricing page, and the FAQ covers how scans and fix PRs work in more detail.