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.1A4.1.2AEN 301 549Section 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.
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
Replace the <div> with a native
<button type="button"> (or <a href> if
it navigates). You get focusability, role, and key handling for free.
If a native element is truly impossible, add role="button" and
tabindex="0" so it’s announced and reachable.
Handle both Enter and Space in a
keydown listener, and call preventDefault() on
Space so the page doesn’t scroll.
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.7AA2.4.11AAEN 301 549ADA
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
Never ship outline: none without a replacement. If you remove
the default ring, add your own visible one.
Use :focus-visible so keyboard users always get the indicator
while pointer clicks stay clean.
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).
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.2AEN 301 549Section 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.
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
Confirm that Tab and Shift+Tab can move focus through every component and
back out to the rest of the page.
For modals, prefer the native <dialog> with
showModal(); it gives a contained-yet-escapable focus loop and
Esc-to-close.
Always provide Esc as an exit, and move focus into the widget on
open, then return it to the triggering control on close.
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.1A2.4.3AEN 301 549ADA
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
Add a skip link as the first focusable element, pointing at
#main, and give the target tabindex="-1" so focus
actually lands there.
Use landmarks (<header>, <nav>,
<main>) so AT users can jump between regions too.
Order the DOM to match the visual reading order; let the natural source
order drive the tab sequence.
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.