Skip to main content
Back to module
Accessible & Responsive Interfaces · Accessibility Essentials

Keyboard and focus

Reading33mLesson 3 of 4 in this module

Make a widget fully keyboard operable with native buttons, visible focus and a skip link.

Why this matters

Everything a mouse can do, a keyboard must do too (WCAG 2.1.1, Level A). Keyboard access is not only for blind users: people with tremors, repetitive strain, temporary injuries, switch devices and voice control all depend on the same focus system. Power users love it too. And if a control is not keyboard operable, screen readers usually cannot operate it either.

How focus works

Only one element has focus at a time; key presses go to it. Tab moves focus forward, Shift+Tab back. Native interactive elements are focusable and respond to the right keys for free:

ElementKeys
<a href>Enter follows the link
<button>Enter and Space activate
<input type="checkbox">Space toggles
Radio groupArrow keys move between options
<select>Arrow keys change, Enter or Space opens
<details>/<summary>Enter and Space toggle

Focus order follows the DOM order (2.4.3). If CSS reorders things visually (order, flex-direction: row-reverse, grid placement), the tab order no longer matches what people see. Fix the source order instead.

tabindex: three values to know

  • tabindex="0" makes a non-interactive element focusable in normal order. Use it rarely, for custom widgets or scrollable regions.
  • tabindex="-1" makes an element focusable by script (el.focus()) but not by Tab. Use it for headings you move focus to or the target of a skip link.
  • tabindex="1" or higher jumps the element ahead of everything else. Never use positive values; they create a confusing order that is hard to maintain.

Make focus visible

WCAG 2.4.7 (AA) requires a visible focus indicator. The most common failure in the wild is one line of CSS:

/* Removes the only clue keyboard users have. Do not do this. */
:focus { outline: none; }

Use :focus-visible instead. Browsers apply it when focus should be shown (keyboard navigation) and usually not after a mouse click:

:focus-visible {
  outline: 3px solid #1a5fb4;
  outline-offset: 2px;
}

Make the indicator contrast at least 3:1 with the background (1.4.11), and keep it from being hidden behind sticky headers (2.4.11). scroll-padding-top on html helps when you have a fixed header.

Skip links

Repeated headers make keyboard users press Tab dozens of times on every page. A skip link is the first focusable element and jumps to the main content (2.4.1 Bypass Blocks):

<a class="skip-link" href="#main">Skip to main content</a>
...
<main id="main" tabindex="-1">...</main>
.skip-link { position: absolute; left: -9999px; }
.skip-link:focus { left: 1rem; top: 1rem; }

Worked example: a disclosure widget

A common FAQ pattern hides an answer until the question is clicked. Here is the broken version:

<div class="question" onclick="toggle()">What is your returns policy?</div>
<div class="answer" style="display:none">30 days, free.</div>

Problems: the div is not focusable, Enter and Space do nothing, and a screen reader cannot tell it opens anything. The fix uses a button and two attributes from the WAI-ARIA Authoring Practices disclosure pattern:

<button type="button" id="faq-toggle" aria-expanded="false" aria-controls="faq-answer">
  What is your returns policy?
</button>
<div id="faq-answer" hidden>30 days, free.</div>

<script>
  const btn = document.getElementById('faq-toggle');
  const panel = document.getElementById('faq-answer');
  btn.addEventListener('click', () => {
    const open = btn.getAttribute('aria-expanded') === 'true';
    btn.setAttribute('aria-expanded', String(!open));
    panel.hidden = open;
  });
</script>
  • The <button> is focusable and fires click on Enter and Space, so one handler covers mouse, keyboard, touch and voice.
  • aria-expanded tells assistive technology whether it is open ("collapsed" or "expanded" is announced).
  • The hidden attribute removes the closed panel from view and from the accessibility tree.

For a simple case, <details><summary> does all of this with no script. Reach for the button version when you need custom styling or behaviour.

Managing focus in dynamic UI

When a dialog opens, move focus into it; when it closes, return focus to the button that opened it. When content is deleted, move focus somewhere sensible instead of losing it to <body>. Do not trap focus unless the UI is truly modal, and always provide a way out (Esc) so you never fail 2.1.2 No Keyboard Trap. The native <dialog> element with showModal() handles much of this for you.

How to test

Put the mouse away. Starting from the address bar, press Tab through the whole page and check: can you see where focus is at every step? Is the order logical? Can you operate every control with Enter, Space, arrows and Esc? Can you get out of every widget? This takes five minutes and finds real bugs every time.

Common mistakes

  • outline: none with no replacement.
  • Click handlers on div or span instead of button.
  • Positive tabindex values.
  • Visual reordering with CSS that scrambles the tab order.
  • Hover-only menus that never open with the keyboard.

Quick recap

  • Every control must work by keyboard; native elements do this for free.
  • Keep focus order logical and focus always visible with :focus-visible.
  • Add a skip link; use tabindex="-1" for script focus targets, never positive values.
  • Disclosure widgets: <button aria-expanded aria-controls> plus hidden.

Try it

Open the Do tab and make a broken FAQ widget fully keyboard operable.

Also in this lesson: