Keyboard Navigation and Focus Order on Static Sites
Keyboard users include people who cannot use a mouse, screen reader users, and a surprising number of developers who simply prefer it. On a static site they need three things: every interactive element must be reachable with Tab, the element that has focus must be visibly marked, and the order in which focus moves must follow the order of the page. Static sites usually get the first right by accident, because they use plain links and buttons. They get the second and third wrong in predictable ways: a theme removes the outline, a mobile menu stays focusable when it is hidden, a scrollable code block cannot be reached, or a "card" wraps its link in a <div onclick>.
This guide fixes keyboard navigation on the reference documentation site from Accessibility for Static Sites: 1,140 pages built with Astro and Starlight plus a custom marketing section. It shows the focus style, the hidden-content rules, the widget fixes, and a Playwright test that keeps tab order from regressing.
Prerequisites
- Access to the site's layout templates and global CSS.
- A local build or preview deploy you can test with the keyboard.
- Playwright installed if you want the regression test, as in End-to-End Tests for Static Sites with Playwright.
Step 1: Audit With the Keyboard Only
Before changing anything, tab through one page per layout and write down what goes wrong. On the reference site, five layouts — home, section index, docs article, blog post and pricing — produced this list:
| Problem | Where | Count |
|---|---|---|
| No visible focus on links | Marketing layout | All links |
| Focus lands on hidden mobile menu links | Every layout, desktop width | 14 invisible stops |
| Scrollable code block unreachable | Docs articles | ~2,600 blocks |
Card link only on a <div> click handler | Home, blog index | 18 cards |
| Tab order jumps from header to footer and back | Pricing | 1 page |
| Tabs widget not keyboard operable | Docs articles | 340 instances |
The audit takes about ten minutes per layout and finds problems no automated tool reports, because "focus ring invisible" and "order makes no sense" are judgements, not rules.
Step 2: A Visible Focus Style Everywhere
The marketing layout had a:focus { outline: none; } from an old reset. Replace it with a single rule that applies to every focusable element and uses :focus-visible, so mouse clicks do not show the ring but keyboard focus always does:
:root { --focus-ring: #1982c4; }
[data-theme='dark'] { --focus-ring: #7cc4f0; }
:where(a, button, input, select, textarea, summary, [tabindex]):focus-visible {
outline: 3px solid var(--focus-ring);
outline-offset: 2px;
border-radius: 2px;
}
WCAG 2.2 adds a criterion for focus appearance: the indicator should have a contrast of at least 3:1 against adjacent colours. A 3 px ring in #1982c4 measures 4.1:1 on white; the dark theme uses a lighter blue that measures 7.4:1 on the dark background. The ring is offset by 2 px so it is not hidden by an element's own border or background.
Step 3: Hidden Means Hidden to the Keyboard Too
The mobile navigation drawer was hidden on desktop with transform: translateX(-100%), which moves it off-screen but leaves its 14 links in the tab order. Keyboard users pressed Tab and the focus vanished for 14 presses. Anything not visible must be removed from both the accessibility tree and the tab order:
.drawer:not(.is-open) { visibility: hidden; }
@media (min-width: 64em) { .drawer { display: none; } }
visibility: hidden and display: none both remove elements from the tab order; transforms, opacity: 0 and clipping do not. For content that animates in, toggle visibility at the end of the closing transition. The newer inert attribute does the same for a whole subtree and is supported in every current browser; the drawer sets inert on the main content while it is open, so focus cannot wander behind it.
Step 4: Make Scrollable Regions Focusable
A <pre> block wider than its container scrolls horizontally. A mouse user can scroll it; a keyboard user can only scroll what has focus, and a <pre> is not focusable. axe reports this as scrollable-region-focusable. The fix is to add tabindex="0" to code blocks, but only when they overflow, so short blocks do not add pointless tab stops. Doing it at build time keeps it out of the page's JavaScript; the measurement is not available at build time, so the reference site uses a rule of thumb — any block with a line over 80 characters gets tabindex="0" — through a rehype plugin covered in Accessible Code Blocks in Documentation Sites. Each focusable block also needs a name, so the plugin adds aria-label="Code: bash" from the fence's language.
Step 5: Real Links and Buttons in Widgets
The home page cards were <div class="card" onclick="location='/docs/'">. They had no keyboard support at all. The fix is a real link with its clickable area expanded by CSS, not a script:
<article class="card">
<h3><a href="/docs/getting-started/" class="card-link">Getting started</a></h3>
<p>Install the CLI and deploy your first site in five minutes.</p>
</article>
.card { position: relative; }
.card-link::after { content: ''; position: absolute; inset: 0; }
The whole card is clickable, the link text is the card heading, and Tab stops once per card. The tabs widget for package managers (npm, pnpm, yarn) was fixed the same way: Starlight's built-in <Tabs> component already implements the WAI-ARIA tabs pattern with arrow-key navigation, and the custom widget it replaced was deleted, along with 2.1 KB of script.
Step 6: Fix Order at the Source
The pricing page used CSS grid with order to place the "Contact sales" card first visually, while the HTML had it last. Visual order and focus order disagreed, so focus jumped around the page. CSS order, flex-direction: row-reverse and grid placement change appearance without changing focus order. The fix was to move the card in the HTML, not to add tabindex. A positive tabindex would have put the card before the header's links on every visit, which is worse.
Step 7: Lock the Order in With a Test
Once each layout's tab sequence is right, a Playwright test records it and fails when it changes:
import { test, expect } from '@playwright/test';
async function tabSequence(page, presses) {
const seq = [];
for (let i = 0; i < presses; i++) {
await page.keyboard.press('Tab');
seq.push(await page.evaluate(() => {
const el = document.activeElement;
const r = el.getBoundingClientRect();
return { name: (el.getAttribute('aria-label') || el.textContent || '').trim().slice(0, 40),
visible: r.width > 0 && r.height > 0 && getComputedStyle(el).visibility !== 'hidden' };
}));
}
return seq;
}
test('docs article tab order', async ({ page }) => {
await page.goto('/docs/getting-started/');
const seq = await tabSequence(page, 12);
expect(seq.every(s => s.visible)).toBe(true);
expect(seq.map(s => s.name)).toMatchSnapshot('docs-tab-order.json');
});
The visible check is what catches the off-screen drawer problem coming back; the snapshot catches order changes. The test runs in about 9 seconds across the five layouts.
Measured Impact
| Measure | Before | After | Tool |
|---|---|---|---|
| Invisible tab stops per page (desktop) | 14 | 0 | Playwright tab script |
| Tab presses from top to article content | 190 | 2 (skip link) | Keyboard, Firefox |
scrollable-region-focusable violations | 2,610 elements | 0 | axe-core 4.10 |
| Keyboard-inoperable widgets | 358 instances | 0 | Manual pass |
| JavaScript removed | — | 2.1 KB | Build output |
Pitfalls & Rollback
- Adding
tabindex="0"to everything. Only interactive or scrollable elements should be tab stops; extra stops slow keyboard users down. - Focus rings that fail contrast in one theme. Define the ring colour per theme.
- Hiding with transforms or opacity. Use
visibility,displayorinert. - Fixing order with positive
tabindex. Change the source order instead. - Rollback: every change is CSS or template markup; reverting the commit restores the previous behaviour. The Playwright snapshot makes any later regression visible in the pull request that causes it.
Conclusion
Keyboard accessibility on a static site comes down to a visible focus ring on every element, nothing focusable that cannot be seen, scrollable regions that can be reached, real links and buttons instead of click handlers, and source order that matches visual order. On the reference site that removed 14 invisible tab stops per page and made 358 widgets operable, with less JavaScript than before.
FAQ
Why not just remove the focus outline and add a hover style?
Hover styles do nothing for keyboard users, who need to see which element will act when they press Enter. Use the focus-visible pseudo-class instead of removing the outline. Browsers show it for keyboard focus and hide it after a mouse click, which removes the original design complaint without hiding focus from people who need it.
Is a positive tabindex ever the right fix for tab order?
Almost never. A positive tabindex moves an element ahead of everything in the natural order on the whole page, and the next template change makes the order wrong in a new way. Fix the source order of the HTML instead, and use tabindex 0 only to make a non-interactive element such as a scrollable region focusable.
How do I test keyboard navigation quickly?
Put the mouse away, load the page and press Tab repeatedly. Check that every interactive element receives focus in reading order, that the focus ring is always visible, that menus and dialogs open with Enter or Space and close with Escape, and that focus never lands on something hidden. Ten minutes per layout is enough for most static sites.
Can automated tools find keyboard problems?
Only some. axe flags scrollable regions that cannot be focused, positive tabindex values and interactive elements without names. It cannot tell whether the focus ring is visible against the background, whether the order makes sense, or whether a menu traps focus. A Playwright script that tabs through a page and records the focused elements catches order regressions once you have a known good sequence.
Related
- Parent: Accessibility for Static Sites — the full set of accessibility layers.
- Skip Links and Landmarks in SSG Layouts — the first stop in the tab order.
- Accessible Code Blocks in Documentation Sites — the rehype plugin for focusable code.
- Accessible Search UI for Static Sites — focus management in a dialog.
- Accessibility Testing with axe and pa11y in CI — the automated checks alongside the tab test.
Filed underHow-tos