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:
| Element | Keys |
|---|---|
<a href> | Enter follows the link |
<button> | Enter and Space activate |
<input type="checkbox"> | Space toggles |
| Radio group | Arrow 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 firesclickon Enter and Space, so one handler covers mouse, keyboard, touch and voice. aria-expandedtells assistive technology whether it is open ("collapsed" or "expanded" is announced).- The
hiddenattribute 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: nonewith no replacement.- Click handlers on
divorspaninstead ofbutton. - Positive
tabindexvalues. - 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>plushidden.
Try it
Open the Do tab and make a broken FAQ widget fully keyboard operable.