Keyboard navigation means a visitor can use your whole site without a mouse: Tab and Shift+Tab move through every link, button and form field in the order they appear, Enter and Space activate them, arrow keys move inside menus and sliders, Escape closes overlays, and a visible focus indicator always shows where you are. If any of that breaks, the page is closed to screen reader users, people with tremors or repetitive strain injuries, switch and voice-control users, and anyone whose trackpad just died.
This guide shows how to test keyboard navigation in ten minutes, the six things that usually go wrong, and the fix for each, including accessible modals. It is one of the ten fixes in the web accessibility guide.
Quick answer
- Every link, button and form field is reachable with Tab and Shift+Tab, in the order it appears on screen.
- The focused element is always visible: never
outline: nonewithout a replacement. - Enter activates links and buttons, Space activates buttons and checkboxes, arrows move inside menus, sliders and tabs.
- Modals keep focus inside while open, close on Escape and return focus to the button that opened them.
<dialog>withshowModal()does most of this for you. - No
tabindexabove 0;tabindex="0"for custom controls,tabindex="-1"for script-only focus targets. - Run the accessibility checker for the automated part, then tab through the page yourself. No tool can detect a keyboard trap from the markup.
Why keyboard navigation matters
A page that works with a keyboard works for every input method. Screen readers drive the page through the keyboard. Voice control ("click Sign in") relies on the same focusable elements and names. Switch access and eye trackers end up sending key presses. When a custom dropdown is a pile of <div>s with click handlers, all of those people are locked out at once.
The fixes are cheap because the browser does the work when you use its own elements: a <button> is focusable, has a focus ring, activates on Enter and Space and announces itself as a button, for free.
WCAG 2.2 covers this in six success criteria: 2.1.1 Keyboard and 2.1.2 No Keyboard Trap (level A), 2.4.3 Focus Order (A), 2.4.7 Focus Visible (AA), and two added in 2.2, 2.4.11 Focus Not Obscured (Minimum) (AA) and 2.4.13 Focus Appearance (AAA). Automated tools check a small slice of them; the rest is ten minutes with the Tab key.
How getReport checks it
The checker renders the page in Chromium and runs axe-core with the WCAG 2.0, 2.1 and 2.2 A/AA rules plus best practices, on the page as loaded. It does not press Tab or click your buttons, so a dialog that opens on click is not tested. Keyboard problems show up in three places:

This finding comes from axe's tabindex rule, so it fires on elements with tabindex="1" or higher. It is informational, carries no weight in the score and is hidden when nothing changes the order. Despite its id, it is not a trap test.
The serious group is where keyboard-related axe rules land: scrollable-region-focusable (a scrolling box whose content cannot be reached by keyboard), aria-hidden-focus (a focusable element hidden from screen readers) and nested-interactive. Open the technical detail for rule ids and selectors.
A control you can reach but cannot identify is the other half of the problem: an icon-only close button on a cookie banner is announced as "button". Accessible names for links and buttons covers the fixes.
Step by step
1. Tab through the page and write down what breaks
Click in the address bar so focus starts at the top, then press Tab repeatedly. On a Mac, turn on System Settings → Keyboard → Keyboard navigation first, and in Safari also Settings → Advanced → "Press Tab to highlight each item on a webpage" (or use Option+Tab). For each press, ask:
- Can I see where focus is?
- Does focus go where I expect: header, content, footer, left to right?
- Did I skip something clickable: a card, an icon, a "×" on a banner?
- When I open a menu, modal or search overlay, does Escape close it, and does focus come back?
- Can I get out? If Tab cycles inside a widget forever, or focus vanishes behind an overlay, that is a trap.
Ten minutes on the home page, a product or article page and one form finds most of it. Testing with a screen reader extends this into a full manual pass.
2. Put the keyboard focus indicator back
The most common failure is one line of CSS, added because someone disliked the outline on click:
/* The line that removes focus for everyone: delete it */
*:focus { outline: none; }:focus-visible already does what that line wanted: it matches when the browser decides a ring should show (keyboard use), not on mouse clicks. In your main stylesheet:
:focus-visible {
outline: 3px solid #1d5fd1; /* 5.8:1 on white */
outline-offset: 2px;
}
/* Dark backgrounds: keep at least 3:1 against the surroundings */
.site-footer :focus-visible { outline-color: #ffffff; }
/* A sticky header must not cover the focused element (WCAG 2.4.11) */
html { scroll-padding-top: 5rem; } /* the header's height */The ring needs 3:1 contrast against what is next to it; the colour contrast guide has the ratios. Cookie bars fixed to the bottom of the screen cause the same "focus obscured" failure; leave room with scroll-padding-bottom.
3. Remove positive tabindex
tabindex="1", "2" or "5" force those elements before everything else, in numeric order, and nobody maintains the numbering. Delete every positive value and put elements in the HTML in the order they appear on screen. Where CSS reorders (flex-direction: row-reverse, order:, grid placement), reorder the HTML instead: tab order follows the source, not the picture.
Two values stay useful: tabindex="0" makes a custom element focusable in its natural place, and tabindex="-1" makes an element focusable by script (element.focus()) but not by Tab, which is what a modal heading or a skip-link target needs.
4. Use native elements for controls
A <div onclick> is not focusable, has no role, ignores Enter and Space, and reads as plain text. Use <button> for actions, <a href> for navigation, <select> for a simple dropdown, <input type="range"> for a slider and <details> for an accordion.
When a design needs a custom widget, follow the ARIA Authoring Practices patterns: one Tab stop per widget, arrow keys inside it ("roving tabindex": the current item has tabindex="0", the others -1, and an arrow keydown moves both the value and the focus).
5. Build accessible modals with <dialog>
A modal is where one small mistake locks a person out of the whole page. Whatever the implementation, it must:
- Move focus into the dialog on open, to the heading or the first safe control, never to "Delete" or "Accept all".
- Keep focus inside while open, with the page behind inert.
- Close on Escape and with a visible, named close button of at least 24 × 24 px.
- Return focus to the element that opened it.
- Carry a label:
aria-labelledbypointing at its heading, so it is announced as "Change your plan, dialog".
The native <dialog> element, opened with showModal(), handles 2 and the Escape part of 3: it makes everything else inert, draws ::backdrop and puts the dialog in the top layer. Current browsers also return focus on close, but storing the trigger covers re-rendered buttons:
<button type="button" id="open-plan">Change plan</button>
<dialog id="plan-dialog" aria-labelledby="plan-title">
<form method="dialog">
<h2 id="plan-title" tabindex="-1">Change your plan</h2>
<p>Switching to Team takes effect at the next billing date.</p>
<button value="cancel">Cancel</button>
<button value="confirm">Switch to Team</button>
</form>
</dialog>
<script>
const dialog = document.getElementById('plan-dialog');
let lastFocus = null;
document.getElementById('open-plan').addEventListener('click', () => {
lastFocus = document.activeElement;
dialog.showModal();
document.body.style.overflow = 'hidden'; // no scrolling behind
dialog.querySelector('#plan-title').focus(); // safe initial focus
});
dialog.addEventListener('close', () => { // Escape and both buttons
document.body.style.overflow = '';
if (lastFocus && document.contains(lastFocus)) lastFocus.focus();
});
</script>Closing on a backdrop click is not built in everywhere: the closedby="any" attribute does it in Chromium browsers and Firefox, and Safari was adding it during 2026, so keep a click handler (event.target === dialog) as a fallback, and skip it for dialogs with forms, where a stray click loses input.
If you are stuck with a <div> dialog from a library, give it role="dialog", aria-modal="true" and a label, set the inert attribute on every sibling of the dialog while it is open, and handle Escape yourself. inert works in every current browser and keeps both Tab and the screen reader's virtual cursor out of the page behind. Bootstrap's Modal, Radix Dialog, Headless UI Dialog and MUI's Dialog do all of this when used as documented.
Non-modal overlays (dropdown menus, date pickers, share popovers) must not trap focus; they close on Escape and when focus leaves. The popover attribute gives them the top layer and light dismiss without modal behaviour:
<button type="button" popovertarget="share-menu">Share</button>
<div id="share-menu" popover>…</div>6. Check the widgets you did not write
- Cookie banner: the modal every visitor meets, and the one the checker sees because it is open on load. Put it first in the DOM or move focus to it, give it
role="dialog"and a label, and offer "Reject all" next to "Accept all". A banner that traps focus with no reject button fails an accessibility rule and a consent rule at once; cookies before consent covers the consent side. - Carousel: can you reach the controls and the slides' links? Slides hidden with
opacity: 0keep their links in the tab order, so focus vanishes into invisible content; useinertordisplay: none. - Chat widgets: many inject a launcher with
tabindex="1"or trap focus once open. Check the vendor's accessibility settings. - Maps and videos: an iframe is one Tab stop into a whole page. Give it a
titleand make sure Tab exits on the other side.
Platform notes
WordPress. Themes print menus as real <a> elements; the usual problem is dropdowns that open on hover only. Current default block themes open submenus by keyboard; older classic themes may need :focus-within added to the hover rule. Page builders (Elementor, Divi, WPBakery) build popups, accordions and tabs from <div>s, and keyboard support varies by widget and add-on; test each one, and enable the popup's "close on Esc" option. Search the theme's CSS for outline: none and outline: 0, and add the :focus-visible rule from step 2 in Customize → Additional CSS.
Shopify. Theme Store themes are reviewed for keyboard support, and Dawn's modals and cart drawer set role="dialog", trap focus and return it on close. Apps that add pop-ups, spin-to-win wheels and chat are not reviewed the same way; test each one with Tab after installing it.
Verify
- Re-run the checker: the tab-order finding is gone and the serious group no longer lists
scrollable-region-focusableoraria-hidden-focus. - Tab through the same three pages: every control gets a visible ring, the order matches the screen, nothing traps you.
- Open a modal with Enter, Tab through it (focus never leaves), press Escape: focus is back on the trigger, visibly.
- With a screen reader, the dialog is announced with its name, and the virtual cursor cannot reach the page behind.
Common mistakes
- A ring that only shows on click.
:focus { outline: none }with a custom:activestyle. Style:focus-visibleinstead. tabindex="1"on the "important" button. It becomes the first Tab stop, before the skip link.- A modal that hides the page with CSS only. Tab goes straight through into the blurred page behind. Use
showModal()orinert. - Focus lost on close. Focus falls to
<body>and the next Tab starts from the top. Store and restore the trigger. - Hover-only menus. Add
:focus-withinto the same rule, or open on click. aria-hidden="true"on something focusable. Focus lands on silence. Remove it from the tab order or drop thearia-hidden.- Off-screen content that is still focusable. Slide-in menus parked at
left: -9999pxare still tabbable; useinertordisplay: nonewhile closed.
Questions people ask
What is keyboard navigation in web accessibility?
Keyboard navigation is using a website with the keyboard alone: Tab and Shift+Tab to move between links, buttons and fields, Enter and Space to activate them, arrow keys inside widgets and Escape to close overlays. WCAG requires that everything a mouse can do also works this way, in a logical order, with a visible focus indicator.
What is a keyboard trap?
A keyboard trap is a part of a page that keyboard focus can enter but not leave, such as a video player, chat widget or badly built modal where Tab cycles forever. WCAG 2.1.2 No Keyboard Trap, level A, forbids it. A modal that keeps focus inside is not a trap as long as Escape or a close button lets you out.
What makes a good keyboard focus indicator?
A good focus indicator is a solid outline at least 2 px thick with at least 3:1 contrast against the colours next to it, offset slightly from the element so it does not blend in. Style it with :focus-visible so mouse users do not see it on click, and make sure sticky headers and cookie bars never cover it.
Should a modal dialog trap focus?
Yes, while it is open. A modal must keep Tab and Shift+Tab inside itself and make the page behind unreachable, then return focus to the button that opened it when it closes. Escape must always close it, which is what separates this intended containment from a keyboard trap. <dialog> with showModal() does this for you.
Where should focus go when a modal opens?
Focus should move into the modal, usually to its heading (given tabindex="-1") or to the first safe control, so screen reader users hear the dialog's name first. Never start on a destructive button such as "Delete" or on "Accept all". When the modal closes, focus must return to the button that opened it.
How do I test keyboard navigation on a Mac?
Turn on System Settings → Keyboard → Keyboard navigation, and in Safari also enable "Press Tab to highlight each item on a webpage" under Settings → Advanced, or hold Option while pressing Tab. Then put the mouse away and Tab through the page, checking that focus is visible, the order makes sense and every overlay closes on Escape.