RizTech Academy logo
RizTech Academy
AccessibilityLesson 3 of 630 min

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.

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

  1. Tab through your whole page. Write down everything you cannot reach and everything where you cannot see the focus.
  2. Press Space on a link styled as a button. Then on a real <button>.
  3. Put a <button> with no type inside a form and click it.
  4. Build a div-button with tabindex="0" and role="button", then try Enter and Space. Replace it with a <button> and appearance: none.
  5. Add tabindex="3" to something in the middle of a page, then tab from the top.
  6. Set :focus { outline: none } and tab through. Then switch to :focus-visible with an offset, and compare clicking with tabbing.
  7. Build the black-ring-plus-white-halo focus style and test it on a white, a dark and a photographic background.
  8. Check your focus ring's contrast in the devtools Accessibility pane.
  9. Add a skip link. Confirm it is first, invisible until focused, and actually moves focus into <main>.
  10. Remove the tabindex="-1" from <main> and see whether the next Tab continues from the header.
  11. Open a <dialog> with showModal(), tab repeatedly, press Escape, and note where focus lands.
  12. Build the same modal from a <div> and compare all four behaviours.
  13. Delete the element that currently has focus and press Tab.
  14. Tab down a long page with a sticky header and find focus hidden underneath. Fix it with scroll-margin-top.
  15. Hide a menu with opacity: 0 and tab into it. Then use display: none, then inert.
  16. Tab all the way round your page back to the address bar, confirming no trap.

Official documentation

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