A keyboard user who opens a page with a 40-link menu has to press Tab 40 times before reaching the content, on every page. A skip link fixes that with one keystroke. Focus management is the wider skill: making sure focus is always somewhere sensible after something on the page changes.
Skip links: 2.4.1 Bypass Blocks
Success criterion 2.4.1 Bypass Blocks (A) requires a way to skip blocks of content that repeat on multiple pages. Landmarks and headings help screen reader users, but keyboard users without a screen reader have no landmark shortcuts. A skip link serves them.
Put it as the first focusable element in the body:
<body>
<a class="skip-link" href="#main">Skip to main content</a>
<header>
<!-- logo, navigation, search -->
</header>
<main id="main" tabindex="-1">
<h1>Opening hours</h1>
</main>
</body>
.skip-link {
position: absolute;
left: 1rem;
top: 1rem;
z-index: 1000;
padding: 0.75rem 1rem;
background: #ffffff;
color: #0b0c0c;
border: 3px solid #0b0c0c;
transform: translateY(-200%);
}
.skip-link:focus {
transform: translateY(0);
}
#main:focus {
outline: none; /* the main region itself does not need a focus ring */
}
How it works:
- The link is off-screen until it receives focus, then slides into view. It must be visible when focused, otherwise sighted keyboard users would not know where focus went.
- Activating it moves focus to
main.tabindex="-1"makesmainfocusable by the link without adding it to the tab order, so the nextTabcontinues from inside the main content in every browser.
Do not hide skip links with display: none or visibility: hidden. Hidden elements cannot receive focus, so the link never appears and never works.
Removing the outline on main is acceptable because main is not an interactive control. Keep outlines on everything users can operate.
Test it: load the page, press Tab once, and check the link appears. Press Enter, then Tab again; focus should land on the first link or control inside main.
Why focus management matters
Focus is the user's position on the page. When you change the page with JavaScript, the browser does not know where focus should go, so you must decide. Two failures are common.
- Lost focus: the focused element is removed from the DOM. Focus usually falls back to the
body, and the nextTabstarts from the top of the page. - Stranded focus: new content appears somewhere else, but focus stays where it was, so keyboard and screen reader users never find it.
2.4.3 Focus Order (A) requires that focus moves in a way that preserves meaning and operability, so both failures can fail WCAG as well as frustrate people.
Moving focus with JavaScript
Use element.focus(). Non-interactive targets such as headings or containers need tabindex="-1" first.
function moveFocusTo(element) {
if (!element.hasAttribute('tabindex') && element.tabIndex < 0) {
element.setAttribute('tabindex', '-1');
}
element.focus();
}
Native focusable elements such as links and buttons have a tabIndex of 0 already, so the helper leaves them alone.
Example: deleting an item from a list
When a user deletes an item, its delete button disappears. Move focus to something meaningful nearby: the next item's delete button, the previous one, or the list heading if the list is now empty.
<h2 id="saved-heading">Saved searches</h2>
<ul id="saved">
<li>Science fiction <button type="button" class="delete" aria-label="Delete Science fiction">Delete</button></li>
<li>Local history <button type="button" class="delete" aria-label="Delete Local history">Delete</button></li>
<li>Graphic novels <button type="button" class="delete" aria-label="Delete Graphic novels">Delete</button></li>
</ul>
<p id="saved-status" role="status"></p>
<script>
const list = document.getElementById('saved');
const heading = document.getElementById('saved-heading');
const status = document.getElementById('saved-status');
list.addEventListener('click', (event) => {
const button = event.target.closest('button.delete');
if (!button) return;
const item = button.closest('li');
const name = item.firstChild.textContent.trim();
const next = item.nextElementSibling || item.previousElementSibling;
item.remove();
status.textContent = `${name} deleted.`;
if (next) {
next.querySelector('button.delete').focus();
} else {
heading.setAttribute('tabindex', '-1');
heading.focus();
}
});
</script>
The status message tells screen reader users what happened, and focus stays in the list. Lesson 18 explains role="status".
Example: showing an error summary
When a form fails validation, move focus to an error summary at the top so users know submission failed and can see what to fix. Lesson 9 builds the full form.
const summary = document.getElementById('error-summary');
summary.hidden = false;
summary.setAttribute('tabindex', '-1');
summary.focus();
Rules of thumb
- Move focus only in response to a user action, such as a click, a submission or a route change. Never move it unexpectedly while someone is reading or typing.
- Move it to where the user's next task begins: the new content, the opened dialog, the result of their action.
- Return it when something closes. When a dialog, menu or popup closes, put focus back on the control that opened it.
- Never let it fall to the body. Before removing a focused element, decide where focus goes next.
- Do not move focus to announce things. If the user should keep working where they are, use a live region instead (lesson 18).
Traps and inert content
When a modal dialog is open, focus should stay inside it, and the content behind should not be reachable. The inert attribute makes an element and everything in it unfocusable and hidden from assistive technology.
<div id="page" inert>
<!-- everything behind an open custom overlay -->
</div>
Remove inert when the overlay closes. The native dialog element opened with showModal() makes the rest of the page inert for you, which is why lesson 15 uses it. An unintended trap, where focus cannot leave a widget at all, fails 2.1.2 No Keyboard Trap.
Summary
- A skip link as the first focusable element satisfies 2.4.1 Bypass Blocks for keyboard users; it must become visible on focus.
- Point skip links at
mainwithtabindex="-1"so focus moves reliably. - When content changes, decide where focus goes: never let it fall back to the
body. - Move focus with
element.focus(), addingtabindex="-1"to non-interactive targets. - Return focus to the trigger when dialogs and menus close.
- Use
inertto block content behind overlays, and live regions for announcements that should not move focus.