Astro ships zero JavaScript by default and renders plain HTML, which gives Astro accessibility a real head start over framework-heavy stacks. But "renders HTML" is not the same as "renders accessible HTML." The compiler outputs exactly the markup you write in your .astro files, so a <div> masquerading as a button, a missing lang, or an interactive island that traps focus all ship straight to production.
This guide covers the accessibility decisions specific to Astro: writing semantic markup in components, choosing hydration directives without breaking the keyboard experience, managing focus across client-side navigation, and testing against WCAG 2.2 Level A and AA, the baseline referenced by EN 301 549 and the European Accessibility Act.
Semantic HTML is the default, so use it
An .astro component is mostly HTML with a JavaScript frontmatter block. That closeness to the platform is Astro's biggest accessibility advantage: there is no JSX abstraction nudging you toward <div> soup. Reach for the correct element before reaching for ARIA. A <button> is focusable, fires on Enter and Space, and exposes its role for free; a <div onclick> does none of that, and patching it back to parity takes role="button", tabindex="0", and a keydown handler most teams forget.
Use <nav>, <main>, <header>, and <footer> for landmarks so screen-reader users can jump between regions. Put exactly one <h1> per page and keep heading levels sequential, since assistive tech builds a navigable outline from them. This matters more in Astro than it looks, because a typical page is stitched together from many small components. Each component contributes headings, and it is easy to ship two <h1> tags or a jump from <h2> to <h4> when a layout and a slotted card both guess at the level.
Set lang once, in your base layout
WCAG 3.1.1 (Language of Page, Level A) requires a valid lang on the root element. Set <html lang="en"> in your shared Layout.astro so every route inherits it, rather than per page. For multilingual sites using Astro's i18n routing, make lang dynamic from the current locale (<html lang={Astro.currentLocale}>) so a German route actually announces in German. Mark inline language changes with lang on the relevant element too.
Islands and hydration: where keyboard access breaks
Astro's islands architecture lets you render a React, Vue, Svelte, or Solid component as static HTML and hydrate only the interactive parts. The directive you choose controls when that JavaScript runs, and that timing has direct accessibility consequences.
client:loadhydrates immediately. Use it for controls a keyboard user might hit right away, like a header menu toggle or skip-link target.client:idlewaits for the main thread to settle. Fine for non-critical widgets, but the control is inert until then.client:visiblehydrates when the island scrolls into view. Great for performance, but a screen-reader user tabbing ahead may reach a control before its JavaScript exists.client:onlyskips server rendering entirely. The element is empty HTML until hydration, so it is invisible to assistive tech (and search engines) until JS loads, and a no-JS user gets nothing.client:mediahydrates only at a breakpoint, which can leave a control non-functional on the wrong viewport.
The trap is the gap between paint and interactivity. With client:visible or client:idle, a button can be on screen and focusable before its handler is attached, so the first Enter press does nothing. For anything a keyboard or screen-reader user reaches early in a flow, prefer client:load, or render a usable static fallback that progressively enhances. And whichever directive you pick, the underlying island still has to be built correctly: an icon-only toggle needs an accessible name and aria-expanded so its state is announced.
Focus and navigation across page transitions
By default Astro is a multi-page app: every link triggers a full document load, the browser resets focus to the top, and screen readers re-announce the new page. That native behavior is accessible for free, which is one of the quieter wins of the architecture.
That changes the moment you add View Transitions with the <ClientRouter />. Now navigation is client-side: the DOM is swapped in place, the browser does not reset focus, and a screen reader may not announce that anything changed. After a swap, focus can be left pointing at an element that no longer exists, stranding keyboard users. Listen for the astro:after-swap event and move focus deliberately, for example to the new page's <h1> (given tabindex="-1") or to a <main> landmark, and consider an aria-live region that announces the new page title. Use transition:persist for elements like a media player or nav that should survive the swap, so focus inside them is not destroyed.
Regardless of routing mode, the navigation fundamentals still apply: a visible skip-to-content link as the first focusable element, a focus indicator you never remove (WCAG 2.4.7, Focus Visible, AA), and a focus order that follows reading order. WCAG 2.2 also adds 2.4.11 Focus Not Obscured (Minimum, AA), so make sure a sticky Astro header never hides the element that currently has focus.
Images, content collections, and the markup you don't write by hand
Astro's <Image /> component from astro:assets requires an alt prop and will error at build if you omit it, which is a genuinely helpful guardrail. But a required field is not a correct field: alt="" is the right answer for decorative images, and a meaningful description is needed for the rest. The build cannot tell the difference, so the alt text itself still needs human judgment.
Most Astro content lives in Markdown or MDX via content collections. That generated HTML is where accessibility quietly degrades: an author writes # Heading mid-article and creates a second <h1>, or pastes a raw URL as link text. Audit the rendered output, not just the source, and consider a remark or rehype plugin to enforce heading order and flag empty links at build time. Tables authored in Markdown also need real <th> headers, with scope set, to be navigable.
Test it, don't assume it
Astro's static-first output makes automated scanning effective, because most of the page is real HTML present on first load rather than rendered after a JavaScript boot. Stack complementary checks where each is cheapest to fix.
- Linting: run an a11y linter (such as eslint-plugin-jsx-a11y on your React/Vue/Svelte islands) to catch missing alt and unlabeled controls as you type.
- Automated scan: run axe-core against your built pages in CI, and scan the deployed URL with a free checker like AccessScan to catch contrast, names, and structure issues fast.
- Keyboard pass: tab through every flow with no mouse, including each hydrated island and any View Transitions route change. You should always see focus, never get trapped, and reach every control in logical order.
- Screen-reader pass: test critical paths with VoiceOver or NVDA. This is the only way to confirm that islands announce their state and that client-side navigation is perceivable.
Automated tools catch only a portion of WCAG issues; the rest need a human pass. Work the accessibility checklist before you call a release done.