Keyboard access & visible focus

The keyboard is the baseline interface for the web. Switch devices, voice control, and screen readers all drive the page through the same focus-and-activation model the keyboard uses, so a feature that works with the keyboard alone tends to work for all of them. The reverse is also true: a control that only answers to a mouse click locks out everyone who can’t point. Two things have to hold. First, every interactive element must be reachable and operable from the keyboard — you can Tab to it, activate it with Enter or Space, and move on. Second, the user must always be able to see and trust where focus is: a visible indicator, an order that follows the visual layout, no traps, and nothing hiding the focused control behind a sticky header.

What you’ll learn

Why a clickable <div> is unreachable and how to make it a real button, how to keep a focus indicator visible and unobscured, how to recognise and escape a keyboard trap, and how to let people bypass repeated blocks while keeping focus order logical.

Standards this lesson maps to
Criterion WCAG 2.2 EN 301 549 Section 508 ADA
2.1.1 Keyboard Level A — all functionality available from a keyboard. 9.2.1.1 (incorporates WCAG 2.1.1) 502.3 / WCAG A WCAG 2.1 AA (DOJ rule)
2.1.2 No Keyboard Trap Level A — focus can move away from any component using the keyboard. 9.2.1.2 (incorporates WCAG 2.1.2) WCAG A WCAG 2.1 AA (DOJ rule)
2.4.3 Focus Order Level A — focus order preserves meaning and operability. 9.2.4.3 (incorporates WCAG 2.4.3) WCAG A WCAG 2.1 AA (DOJ rule)
2.4.7 Focus Visible Level AA — the keyboard focus indicator is visible. 9.2.4.7 (incorporates WCAG 2.4.7) WCAG AA WCAG 2.1 AA (DOJ rule)
2.4.11 Focus Not Obscured (Min.) Level AA (new in 2.2) — the focused control isn’t entirely hidden. Referenced via the WCAG 2.2 update WCAG AA (per applicable update) WCAG 2.1 AA baseline; 2.2 where adopted
2.4.1 Bypass Blocks Level A — a mechanism skips blocks repeated on pages. 9.2.4.1 (incorporates WCAG 2.4.1) WCAG A WCAG 2.1 AA (DOJ rule)
4.1.2 Name, Role, Value Level A — custom controls expose role, state, and a name. 9.4.1.2 (incorporates WCAG 4.1.2) 502.3 / WCAG A WCAG 2.1 AA (DOJ rule)

The four problems we’ll fix

The cards below each isolate one common keyboard or focus defect. For every issue you get a plain-language statement of the problem, a Bad example (shown as escaped, non-running code so it can’t harm or trap this page), a Good example, the copyable Code, and an ordered fix.

Clickable <div> that isn’t keyboard operable

WCAG 2.2 · 2.1.1 A 4.1.2 A EN 301 549 Section 508

A <div> or <span> with a click handler looks like a button but isn’t one. It isn’t in the tab order, so a keyboard user can never reach it; it has no role, so a screen reader announces nothing useful; and it ignores Enter and Space. The feature works with a mouse and is completely unavailable without one.

Bad

A styled <div> with only an onclick handler. It is unfocusable, roleless, and deaf to the keyboard.

bad-div-button.html
<div class="btn" onclick="save()">Save</div>

<!-- Not in the tab order, no role, ignores Enter/Space -->

Good

Use a real <button>. It is focusable, announced as a button, and fires on click, Enter, and Space with no extra code. This live button is fully keyboard operable.

good-button.html
<button type="button" class="btn" onclick="save()">Save</button>

Code

If a native element is genuinely impossible, add the role, put it in the tab order, and handle both Enter and Space yourself — the contract a native button gives you for free.

role-button-fallback.html
<div class="btn" role="button" tabindex="0"
     onclick="save()"
     onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();save();}">
  Save
</div>

How to fix

  1. Replace the <div> with a native <button type="button"> (or <a href> if it navigates). You get focusability, role, and key handling for free.
  2. If a native element is truly impossible, add role="button" and tabindex="0" so it’s announced and reachable.
  3. Handle both Enter and Space in a keydown listener, and call preventDefault() on Space so the page doesn’t scroll.
  4. Verify you can Tab to the control and activate it with Enter and Space, and that it reads as “Save, button”.

Focus indicator is removed and never replaced

WCAG 2.2 · 2.4.7 AA 2.4.11 AA EN 301 549 ADA

A blanket :focus { outline: none } (often added to “clean up” the design) deletes the only signal a keyboard user has for where they are on the page. They Tab and nothing visibly moves, so they’re lost. A related failure is a focused control that is outlined but sits behind a sticky header or cookie bar, so the indicator is hidden anyway.

Bad

The outline is stripped from every focusable element with no replacement, so keyboard focus becomes invisible.

bad-focus.css
/* Deletes the focus ring for everyone, everywhere */
*:focus {
  outline: none;
}

Good

Provide a clear, high-contrast :focus-visible indicator with an offset so it stands off the control’s edge. Tab to this live button to see a thick focus ring; a mouse click won’t show it.

good-focus.css
/* A visible ring, only for keyboard focus, with contrast + offset */
:focus-visible {
  outline: 3px solid #1a56db;   /* meets contrast against the page */
  outline-offset: 2px;
  border-radius: 4px;
}
/* Optional: hide the ring on pointer focus only */
:focus:not(:focus-visible) {
  outline: none;
}

Code

For sticky headers, reserve space with scroll-margin-top so a focused or linked target isn’t hidden under the bar — this is what 2.4.11 Focus Not Obscured asks for.

not-obscured.css
/* Keep focused targets clear of a sticky header (2.4.11) */
:target,
:focus-visible {
  scroll-margin-top: 5rem;   /* >= the sticky header height */
}

How to fix

  1. Never ship outline: none without a replacement. If you remove the default ring, add your own visible one.
  2. Use :focus-visible so keyboard users always get the indicator while pointer clicks stay clean.
  3. Make the indicator obvious: a thick outline with an outline-offset that contrasts at least 3:1 against the background (WCAG 2.4.11 / 1.4.11).
  4. Add scroll-margin-top so a focused control is never fully hidden behind a sticky header (2.4.11 Focus Not Obscured).

Keyboard trap — you can Tab in but not out

WCAG 2.2 · 2.1.2 A EN 301 549 Section 508

A keyboard trap is a region you can Tab into but not out of — focus loops inside a custom widget, or a modal grabs focus and offers no way to leave. For a keyboard-only user this can freeze the entire page; their only escape may be to close the tab. Because a trap can literally strand a visitor, the examples here are escaped, non-running code — we never build a live trap on this page.

Bad

A “focus-locking” handler that always sends focus back to the first field forms a loop: Tab and Shift+Tab can never leave the dialog, and there is no Esc handler.

bad-trap.js
// Anti-pattern: focus can never leave, and Esc does nothing.
dialog.addEventListener('focusout', function () {
  firstField.focus();   // always yanked back inside
});
// No keydown for Escape, no Shift+Tab handling — this is a trap.

Good

The best fix is the native <dialog> element: the browser manages a contained-but-escapable focus loop and closes on Esc. Manage focus on open and return it to the trigger on close.

good-dialog.html
<dialog id="settings" aria-labelledby="settings-title">
  <h2 id="settings-title">Settings</h2>
  <!-- fields -->
  <button type="button" data-close>Close</button>
</dialog>

<script>
  const dlg = document.getElementById('settings');
  openBtn.addEventListener('click', () => dlg.showModal()); // focus moves in
  dlg.querySelector('[data-close]')
     .addEventListener('click', () => dlg.close());
  // showModal() gives a contained focus loop AND Esc-to-close for free.
  dlg.addEventListener('close', () => openBtn.focus());      // return focus
</script>

Code

If you can’t use <dialog>, trap focus deliberately but escapably: wrap Tab and Shift+Tab between the first and last focusable elements, and always close on Esc.

manual-focus-loop.js
modal.addEventListener('keydown', function (e) {
  if (e.key === 'Escape') { close(); return; }   // always an exit
  if (e.key !== 'Tab') return;
  const f = modal.querySelectorAll(
    'a[href],button,input,select,textarea,[tabindex]:not([tabindex="-1"])');
  const first = f[0], last = f[f.length - 1];
  if (e.shiftKey && document.activeElement === first) {
    last.focus(); e.preventDefault();            // wrap backward
  } else if (!e.shiftKey && document.activeElement === last) {
    first.focus(); e.preventDefault();           // wrap forward
  }
});

How to fix

  1. Confirm that Tab and Shift+Tab can move focus through every component and back out to the rest of the page.
  2. For modals, prefer the native <dialog> with showModal(); it gives a contained-yet-escapable focus loop and Esc-to-close.
  3. Always provide Esc as an exit, and move focus into the widget on open, then return it to the triggering control on close.
  4. If you build a manual focus loop, wrap at the first and last focusable elements only — never re-pin focus on every focusout.

No way to bypass repeated blocks; illogical focus order

WCAG 2.2 · 2.4.1 A 2.4.3 A EN 301 549 ADA

With no skip link, a keyboard or screen-reader user has to Tab through the entire header — logo, full navigation, search, theme controls — on every page before reaching the content. Worse, positive tabindex values force focus into an order that doesn’t match the visual layout, so people land in unexpected places and lose their bearings.

Bad

No skip link, and positive tabindex values rearrange the tab order so it no longer follows reading order.

bad-order.html
<!-- No skip link before the header -->
<header>… long nav, search, theme switcher …</header>

<!-- Positive tabindex jumps focus out of visual order -->
<input tabindex="3" name="q">
<button tabindex="1">Go</button>
<a tabindex="2" href="/help">Help</a>

Good

A skip link is the first focusable element and jumps to #main; DOM order matches visual order, and no positive tabindex is used. This very page does exactly that — Tab from the address bar and the first stop is “Skip to main content”.

good-skip-link.html
<body>
  <a class="skip-link" href="#main">Skip to main content</a>
  <header>…</header>

  <main id="main" tabindex="-1">
    <!-- content; tabindex="-1" lets the target receive focus -->
  </main>
</body>

Code

The skip link is visually hidden until focused, then revealed. Use only tabindex="0" (in order) or -1 (programmatic focus) — never a positive value.

skip-link.css
.skip-link {
  position: absolute;
  left: -9999px;          /* off-screen, still focusable */
}
.skip-link:focus {
  left: 1rem;
  top: 1rem;              /* visible when it receives focus */
}

How to fix

  1. Add a skip link as the first focusable element, pointing at #main, and give the target tabindex="-1" so focus actually lands there.
  2. Use landmarks (<header>, <nav>, <main>) so AT users can jump between regions too.
  3. Order the DOM to match the visual reading order; let the natural source order drive the tab sequence.
  4. Never use a positive tabindex. Stick to 0 and -1 so focus order stays predictable.

Recap

  • Use native interactive elements — a real <button> or <a> is focusable, operable, and announced for free (2.1.1, 4.1.2).
  • Never remove the focus outline without replacing it; provide a clear :focus-visible indicator and keep it out from behind sticky headers (2.4.7, 2.4.11).
  • Make sure focus can always move out of a widget with Tab and Shift+Tab, and that dialogs close on Esc and return focus (2.1.2).
  • Offer a skip link to #main and keep DOM order matching the visual order — avoid positive tabindex (2.4.1, 2.4.3).

Build on native semantics and a logical, visible focus path and you satisfy WCAG, EN 301 549, Section 508, ADA, and AODA together.