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

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:

ProblemWhereCount
No visible focus on linksMarketing layoutAll links
Focus lands on hidden mobile menu linksEvery layout, desktop width14 invisible stops
Scrollable code block unreachableDocs articles~2,600 blocks
Card link only on a <div> click handlerHome, blog index18 cards
Tab order jumps from header to footer and backPricing1 page
Tabs widget not keyboard operableDocs articles340 instances
Number of distinct keyboard problems per layout before the fixes Horizontal bars. The docs article layout had 3 distinct keyboard problems, the marketing home page 3, the blog index 2, the pricing page 2 and the section index 1. Keyboard problems found per layout Docs article 3 Marketing home 3 Blog index 2 Pricing 2 Section index 1
Counted during a keyboard-only pass on Firefox; a problem counts once per layout however many elements it affects.

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.

Effect of CSS hiding techniques on keyboard focus and screen readers A matrix of five hiding techniques. display none hides visually, removes from the tab order and from screen readers. visibility hidden does the same. The inert attribute leaves it visible but removes it from the tab order and screen readers. opacity 0 hides visually but keeps it focusable and announced. An off-screen transform hides visually but keeps it focusable and announced. Which hiding techniques remove an element from the tab order Technique Visible Tab order Screen reader display: none hidden removed removed visibility: hidden hidden removed removed inert attribute unchanged removed removed opacity: 0 hidden still focusable still announced off-screen transform hidden still focusable still announced
The two techniques at the bottom are the usual cause of focus disappearing on static sites.

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.

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.

Steps of the Playwright tab order regression test Four steps. Load one page per layout. Press Tab twelve times, recording the name of the focused element and whether it is visible. Fail if any focused element is invisible. Compare the sequence of names with the stored snapshot and fail on any difference. How the tab-order test catches regressions Load layout page 5 layouts Press Tab 12 times record name + visibility Any invisible? fail the test Compare snapshot fail on order change
An intentional change to the layout updates the snapshot in the same pull request.

Measured Impact

MeasureBeforeAfterTool
Invisible tab stops per page (desktop)140Playwright tab script
Tab presses from top to article content1902 (skip link)Keyboard, Firefox
scrollable-region-focusable violations2,610 elements0axe-core 4.10
Keyboard-inoperable widgets358 instances0Manual pass
JavaScript removed—2.1 KBBuild 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, display or inert.
  • 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.

Filed underHow-tos