Keyboard navigation and focus states
If you do one accessibility thing, do this: tab through your page. It takes a minute, it needs no software, and it finds more real problems than any automated tool. Almost every serious accessibility failure shows up as something you cannot reach, cannot see, or cannot escape.
The keys
Tab next interactive element
Shift + Tab previous
Enter activate a link; activate a button
Space activate a button; toggle a checkbox; scroll the page
Arrow keys move within a radio group, a select, a slider
Escape close a dialog or a menu
Home / End start / end of a list or page
Two of those are worth noticing.
Enter and Space differ. A <button> responds to both; an <a> responds only to
Enter. So a link styled as a button fails for anyone who presses Space, which many
people do. That is one reason the <a> versus <button> distinction below is not
pedantry.
Space scrolls the page when nothing focusable is focused. A custom component that
swallows Space breaks scrolling.
What is focusable, and what is not
Focusable by default, in DOM order:
<a href="…"> <button> <input> <select> <textarea>
<summary> <audio controls> <video controls> [contenteditable]
Not focusable: <div>, <span>, <p>, an <a> with no href, and any
disabled control.
That <a> with no href is the commonest accidental failure:
<a onclick="openMenu()">Menu</a> <!-- not focusable, not a link -->
<a href="#" onclick="openMenu()">Menu</a> <!-- focusable, but lies about what it does -->
<button type="button" onclick="openMenu()">Menu</button> <!-- correct -->
<a> or <button>
A link goes somewhere. A button does something. That is the whole rule.
If the thing changes the URL — a page, a section, a file — it is an <a>. If it acts on
the current page — open, submit, toggle, delete — it is a <button>.
It matters because the behaviour differs: links are in the links list, can be opened in a new tab, are followed by search engines, and respond only to Enter. Buttons are in the buttons list, respond to Enter and Space, and are announced as "button" so a listener knows something will happen rather than that they will be taken away.
Always write type on a button. Inside a form, <button> defaults to submit — so
your "show more" button submits the form. Module 1 said this; it is worth twice.
tabindex, and its three values
<div tabindex="0"> <!-- focusable, in normal DOM order -->
<div tabindex="-1"> <!-- focusable by script only, NOT by Tab -->
<div tabindex="3"> <!-- focusable, jumped to the front of the order. Do not -->
tabindex="0" makes something focusable in the natural order. You need it when you
have built a custom interactive element from a <div> — and the better move is usually
not to have built it from a <div>.
tabindex="-1" is the useful one, and it is for programmatic focus. A heading
you want to send focus to after a route change, or a container you focus when showing an
error. It stays out of the tab sequence.
A positive tabindex is almost always a bug. Any element with tabindex="1" or
higher is visited before every natural element on the page, in numeric order. Add one
in a component and you have reordered tabbing for the entire document from somewhere
nobody will look. There is effectively no legitimate use.
Making a div into a button is three jobs, not one
<div class="btn" tabindex="0" role="button" onclick="save()">Save</div>
Still broken: it does not respond to Enter or Space, because a <div> has no default
activation behaviour. You would need a keydown handler for both keys, plus
aria-disabled handling, plus the focus ring, plus the right announcement when
disabled.
<button type="button" class="btn" onclick="save()">Save</button>
All of it, free. appearance: none removes a button's default look completely, so
there is no styling reason to avoid it.
Focus must be visible
/* the most damaging line in web development */
:focus { outline: none; }
A keyboard user with no focus ring has no idea where they are. It is like using a mouse with an invisible cursor.
From module 2, with the full pattern:
:focus-visible {
outline: 3px solid var(--focus, #1a4d2e);
outline-offset: 2px;
border-radius: 2px;
}
:focus-visible fires for keyboard focus and not for a mouse click, which removes the
only reason anybody ever had for deleting the ring.
Three details that matter:
outline-offset lifts the ring off the element so it is visible against a busy
background or a rounded corner.
Use outline, not box-shadow, because Windows high-contrast mode does not render
box-shadow — so a shadow-based ring vanishes for exactly the users most likely to need
it. This was flagged in module 2 and it is the reason.
Contrast applies to the ring. WCAG 2.2 asks for a focus indicator with at least 3:1 against what is behind it. A pale ring on a white background technically exists and practically does not.
A useful trick for a ring that works on any background:
:focus-visible {
outline: 2px solid #000;
outline-offset: 2px;
box-shadow: 0 0 0 4px #fff; /* a white halo outside the black ring */
}
Black-on-white and white-on-black at once, so it is visible either way. The outline
survives forced colours; the halo is a bonus.
Skip links
A keyboard user landing on your page must tab through the whole header on every page before reaching the content. A skip link fixes it:
<body>
<a class="skip-link" href="#main">Skip to main content</a>
<header>…</header>
<main id="main" tabindex="-1">…</main>
.skip-link {
position: absolute;
left: -9999px;
top: 0;
background: #fff;
color: #1a4d2e;
padding: 0.75rem 1rem;
z-index: 999;
}
.skip-link:focus {
left: 0;
}
It must be the first focusable thing in the document, and it must become visible
when focused — a permanently hidden skip link is useless, and a skip link hidden with
display: none is not focusable at all.
The tabindex="-1" on <main> is there because in some browsers following a fragment
link moves the scroll position but not the focus, so the next Tab continues from the
header. Making the target programmatically focusable fixes it.
Note :focus rather than :focus-visible here — you always want it to appear when it
receives focus.
Focus management
Three situations where focus needs moving deliberately.
Opening a dialog. Focus moves into it, is trapped there, and returns to the trigger
on close. The <dialog> element from module 1 does all of it; a <div> modal does none
of it.
Closing something. Focus goes back to whatever opened it. Without this, focus falls to the top of the document and the user has to start again.
Deleting the focused element. If you remove the element that has focus, focus falls
to <body> and the next Tab starts from the beginning. Move focus somewhere sensible
first — the next item, or the list's heading.
The principle: focus must never be lost, and must never move without the user causing it. A page that steals focus on load, or moves it on a timer, is disorienting.
Focus traps
Two kinds, and one is deliberate.
The good trap: a modal dialog holds focus while open. <dialog> with showModal()
does this natively.
The bad trap: something you cannot Tab out of. An embedded editor that swallows Tab, a custom widget with a keydown handler that never releases. WCAG calls this "no keyboard trap" and it is a Level A failure, because the user's only recourse is to close the tab.
The test is simple: Tab all the way round the page and back to the address bar. If you cannot get out of something, that is a trap.
Two more failures worth knowing
Focus hidden behind a sticky header. Tab down a long page and an element gets focus while sitting under your fixed header — focused, invisible, and the user thinks nothing happened. The fix is one property:
:target, [id] { scroll-margin-top: 5rem; } /* the header's height */
scroll-margin-top tells the browser to leave room when scrolling something into view.
display: none on something focusable. Hiding a menu with display: none correctly
removes it from the tab order. Hiding it with opacity: 0, visibility: hidden on a
child only, or by moving it off-screen with position: absolute; left: -9999px leaves it
focusable — so a keyboard user tabs into an invisible menu and is lost. Use
display: none, the hidden attribute, or the inert attribute for a whole subtree.
Check your work
The one-minute test. Tab through the page.
Why Enter and Space differ. A <button> takes both; an <a> takes only Enter.
What is not focusable. <div>, <span>, an <a> without href, anything
disabled.
<a> versus <button>. A link goes somewhere; a button does something.
Why write type on every button. In a form it defaults to submit.
What tabindex="-1" is for. Programmatic focus, staying out of the tab order.
Why a positive tabindex is a bug. It jumps ahead of every natural element on the
whole page.
Why a div-button is three jobs. No keyboard activation, no disabled semantics, no ring.
Why :focus-visible. It does not fire on mouse click, so there is no reason left to
remove the ring.
Why outline rather than box-shadow. Forced-colors mode does not render shadows.
What a focus ring needs. 3:1 contrast against its surroundings.
Two rules for a skip link. First focusable element, and visible when focused.
Why tabindex="-1" on the skip target. Some browsers move scroll without moving
focus.
The keyboard trap test. Tab all the way round and back to the address bar.
What hides focus behind a sticky header. Missing scroll-margin-top.
Which hiding techniques leave things focusable. opacity: 0, off-screen
positioning — use display: none, hidden or inert.
Practice
- Tab through your whole page. Write down everything you cannot reach and everything where you cannot see the focus.
- Press Space on a link styled as a button. Then on a real
<button>. - Put a
<button>with notypeinside a form and click it. - Build a div-button with
tabindex="0"androle="button", then try Enter and Space. Replace it with a<button>andappearance: none. - Add
tabindex="3"to something in the middle of a page, then tab from the top. - Set
:focus { outline: none }and tab through. Then switch to:focus-visiblewith an offset, and compare clicking with tabbing. - Build the black-ring-plus-white-halo focus style and test it on a white, a dark and a photographic background.
- Check your focus ring's contrast in the devtools Accessibility pane.
- Add a skip link. Confirm it is first, invisible until focused, and actually moves
focus into
<main>. - Remove the
tabindex="-1"from<main>and see whether the next Tab continues from the header. - Open a
<dialog>withshowModal(), tab repeatedly, press Escape, and note where focus lands. - Build the same modal from a
<div>and compare all four behaviours. - Delete the element that currently has focus and press Tab.
- Tab down a long page with a sticky header and find focus hidden underneath. Fix it
with
scroll-margin-top. - Hide a menu with
opacity: 0and tab into it. Then usedisplay: none, theninert. - Tab all the way round your page back to the address bar, confirming no trap.
Official documentation
- MDN — tabindex — All three values, with an explicit warning about positive numbers.
- MDN — :focus-visible — When the browser decides a ring is warranted.
- MDN — inert — Removing a whole subtree from focus and assistive technology.
- W3C — Understanding WCAG 2.1.2 No Keyboard Trap — The Level A criterion, and what counts as a trap.
- W3C — Understanding WCAG 2.4.11 Focus Not Obscured — The sticky-header problem, as a formal requirement.
- W3C — Understanding WCAG 2.4.13 Focus Appearance — The size and contrast a focus indicator needs.
- WAI-ARIA Authoring Practices — Developing a keyboard interface — The expected keys for every common widget, if you must build one.
Next: contrast, and not relying on colour.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship