Keyboard Navigation & Focus Management
Many people use the web without a mouse: screen reader users, people with tremors or paralysis who use switches or special keyboards, power users, and anyone with a broken touchpad. For them, focus, the marker that shows which element will receive key presses, is how they move around a page.
In this lesson you will learn the standard keys, keep the focus order logical, use tabindex safely, and manage focus with JavaScript when you add, remove or hide content. These skills cover several level A WCAG criteria that many real sites fail.
The keyboard contract
Users expect these keys to work the same way everywhere:
| Key | Expected action |
|---|---|
| Tab / Shift+Tab | Move to the next / previous focusable element |
| Enter | Follow a link, press a button, submit a form |
| Space | Press a button, tick a checkbox, open a select |
| Arrow keys | Move inside a group: radio buttons, select options, tabs, menus |
| Esc | Close a dialog, menu or pop-up |
| Home / End | Jump to the first / last item in a list-like widget |
The relevant WCAG criteria are all level A:
- 2.1.1 Keyboard: all functionality works from a keyboard.
- 2.1.2 No Keyboard Trap: if focus can move into something, it can move out again with the keyboard.
- 2.4.3 Focus Order: focus moves in an order that keeps meaning and makes sense.
- 3.2.1 On Focus: focusing an element does not cause a big change, such as submitting a form or opening a new window.
Native links, buttons and form fields already follow this contract. The work begins when you build custom widgets or change the page with JavaScript.
Focus order and tabindex
Focus follows the order of elements in the HTML source, not the order on screen. If CSS moves things around with order, flex-direction: row-reverse or grid placement, the visual order and the focus order can disagree, and keyboard users see focus jump around. Keep the source order the same as the reading order.
The tabindex attribute changes focusability:
tabindex="0"adds an element to the Tab order at its natural position. Use it only for custom widgets.tabindex="-1"makes an element focusable by script (element.focus()) but not by Tab. Use it for headings, error summaries or containers you move focus to.- A positive value such as
tabindex="3"jumps the element ahead of everything else. Avoid it: it breaks the natural order and is very hard to maintain.
Run this example and press Tab several times.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Tab Order</title>
<style>
.row { display: flex; gap: 8px; margin-bottom: 12px; }
.reversed { flex-direction: row-reverse; justify-content: flex-end; }
</style>
</head>
<body>
<h1>Pick a seat class</h1>
<h2>Positive tabindex (bad)</h2>
<div class="row">
<button tabindex="3">Sleeper</button>
<button tabindex="1">3A</button>
<button tabindex="2">2A</button>
</div>
<h2>CSS row-reverse (bad)</h2>
<div class="row reversed">
<button>Sleeper</button>
<button>3A</button>
<button>2A</button>
</div>
<h2>Source order matches screen order (good)</h2>
<div class="row">
<button>Sleeper</button>
<button>3A</button>
<button>2A</button>
</div>
</body>
</html>The positive tabindex buttons are focused first, in a strange order, before anything else on the page. The reversed row looks like "2A, 3A, Sleeper" but focus goes "Sleeper, 3A, 2A". Only the last row behaves as users expect.
Managing focus with JavaScript
When your script changes the page, think about where focus is and where it should go. If the focused element disappears, browsers usually drop focus back to the start of the page, and a keyboard user must Tab all the way back. Common cases:
- Deleting an item: move focus to the next item, or to the list heading if none are left.
- Opening a dialog or menu: move focus inside it; on close, return focus to the button that opened it. A native
<dialog>opened withshowModal()does both for you. - Showing form errors: move focus to an error summary or the first invalid field.
- Loading a new view in a single-page app: move focus to the new main heading.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Delete and Keep Focus</title>
</head>
<body>
<h1 id="cart-heading" tabindex="-1">Your cart</h1>
<ul id="cart">
<li>Cotton kurta, ₹799 <button type="button">Remove cotton kurta</button></li>
<li>Jaipur quilt, ₹2,150 <button type="button">Remove Jaipur quilt</button></li>
<li>Brass diya set, ₹450 <button type="button">Remove brass diya set</button></li>
</ul>
<p id="status" role="status"></p>
<script>
const cart = document.querySelector("#cart");
cart.addEventListener("click", (event) => {
const button = event.target.closest("button");
if (!button) return;
const item = button.closest("li");
const next = item.nextElementSibling || item.previousElementSibling;
const name = button.textContent.replace("Remove ", "");
item.remove();
document.querySelector("#status").textContent = name + " removed from cart.";
if (next) {
next.querySelector("button").focus();
} else {
document.querySelector("#cart-heading").focus();
}
});
</script>
</body>
</html>Tab to "Remove Jaipur quilt" and press Enter. Focus moves to the next item's button instead of being lost, and the status message tells screen reader users what happened. When the last item goes, focus moves to the heading, which has tabindex="-1" so script can focus it.
Blocking the background with inert
When a side menu slides over the page, keyboard users should not be able to Tab into the page behind it. The inert attribute makes an element and everything inside it unfocusable, unclickable and hidden from assistive technology.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Side Menu with inert</title>
<style>
#menu { position: fixed; inset: 0 auto 0 0; width: 220px; background: #0b5cad; color: #fff; padding: 16px; }
#menu a { color: #fff; display: block; margin: 8px 0; }
#menu[hidden] { display: none; }
</style>
</head>
<body>
<div id="page">
<button type="button" id="open" aria-expanded="false" aria-controls="menu">Menu</button>
<h1>Kochi Fish Market</h1>
<p><a href="#prices">Today's prices</a></p>
</div>
<nav id="menu" aria-label="Main" hidden>
<button type="button" id="close">Close menu</button>
<a href="#">Fresh catch</a>
<a href="#">Dry fish</a>
<a href="#">Contact</a>
</nav>
<script>
const page = document.querySelector("#page");
const menu = document.querySelector("#menu");
const openBtn = document.querySelector("#open");
function openMenu() {
menu.hidden = false;
page.inert = true;
openBtn.setAttribute("aria-expanded", "true");
document.querySelector("#close").focus();
}
function closeMenu() {
menu.hidden = true;
page.inert = false;
openBtn.setAttribute("aria-expanded", "false");
openBtn.focus();
}
openBtn.addEventListener("click", openMenu);
document.querySelector("#close").addEventListener("click", closeMenu);
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && !menu.hidden) closeMenu();
});
</script>
</body>
</html>While the menu is open, Tab moves only through the menu links (and out to the browser's own controls), Esc closes it, and focus returns to the Menu button.
Common mistakes
- Mouse-only events.
mouseovermenus anddblclickactions with no keyboard equivalent. - Positive
tabindexvalues. Use source order instead. tabindex="0"on non-interactive text. Paragraphs and headings in the Tab order waste keystrokes; screen reader users read them in other ways.- Losing focus after removing content. Always decide where focus goes next.
- Keyboard traps. Embedded widgets or custom modals that capture Tab and never release it.
Key points
- Everything must work with the keyboard (2.1.1) and focus must never get stuck (2.1.2).
- Focus follows source order; keep it the same as the visual order (2.4.3).
- Use
tabindex="0"for custom widgets,tabindex="-1"for script focus targets, and never positive values. - When content is removed, opened or loaded, move focus somewhere sensible with
element.focus(). - Use
inertto block background content behind open menus or panels.
Exercise
Build a "Show more results" button for a list of five colleges. When pressed, it adds five more colleges to the list. Make focus move to the first newly added college name (make it focusable by script only), and add a status message saying how many results are now shown.
Show solution
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Show More</title>
</head>
<body>
<h1>Engineering colleges in Pune</h1>
<ul id="list">
<li>College 1</li><li>College 2</li><li>College 3</li><li>College 4</li><li>College 5</li>
</ul>
<button type="button" id="more">Show more results</button>
<p id="status" role="status"></p>
<script>
const list = document.querySelector("#list");
document.querySelector("#more").addEventListener("click", () => {
const start = list.children.length + 1;
let first;
for (let i = start; i < start + 5; i++) {
const li = document.createElement("li");
li.textContent = "College " + i;
li.tabIndex = -1;
list.append(li);
first ??= li;
}
first.focus();
document.querySelector("#status").textContent = list.children.length + " results shown.";
});
</script>
</body>
</html>Focus moves to "College 6", so keyboard users continue reading from the new results instead of starting again at the top. tabIndex = -1 lets script focus the item without adding every item to the Tab order.
Quick Quiz
1. What does tabindex="-1" do?
Use -1 for headings or containers you move focus to with element.focus().
2. Which WCAG criterion is failed when focus can enter a widget but cannot leave it with the keyboard?
2.1.2 requires that users can always move focus away using the keyboard.
3. A user deletes the second of three items. Where should focus go?
Moving focus to a nearby item keeps the user's place in the list.
4. What does the inert attribute do?
inert blocks interaction with background content while a menu or panel is open.
Learn Web Accessibility with a mentor
Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
