Astro Accessibility Checklist 2026 | WCAG 2.1 AA & EAA Compliance
Last updated: 2026-04-21
Astro has rapidly become one of the most popular frameworks for content-driven websites, including documentation portals, marketing sites, technical blogs, and small ecommerce storefronts. Its static-first, islands-based rendering model ships minimal JavaScript by default, which is genuinely good for accessibility: lighter pages mean assistive technologies stay responsive, and server-rendered HTML works without requiring script execution. However, Astro gives developers an enormous amount of flexibility, and the same freedom that makes it productive also makes it possible to ship inaccessible pages in a dozen different ways. A typical Astro project mixes .astro components, MDX content, content collections with Zod schemas, and one or more UI framework integrations (React, Preact, Vue, Svelte, SolidJS) for the interactive islands. Each of those layers introduces its own accessibility pitfalls. On top of that, Astro's View Transitions API (shipped as a core feature in Astro 4+ and the default navigation behavior in many starter templates) changes how focus, announcements, and heading hierarchy behave between page loads. This checklist walks through the accessibility issues that are specific to how Astro projects are built, from content collection schemas that make alt text optional, to hydrated islands that trap keyboard focus, to
Common Accessibility Issues
Most Astro starter templates define content collection schemas in src/content/config.ts where the image helper returns an image object but the alt text is a separate optional string field, or missing entirely. Authors writing MDX or Markdown post frontmatter skip the field, and the rendered <img> element emits no alt attribute.
Require alt text at the schema level. Define every image-containing content collection with a z.object that bundles the image and alt together, and mark alt as a required string. Fail the build when it is missing rather than relying on editorial discipline.
// src/content/config.ts
import { defineCollection, z } from 'astro:content';
const blog = defineCollection({
schema: ({ image }) => z.object({
title: z.string(),
heroImage: image().optional(), // alt text not enforced anywhere
}),
}); // src/content/config.ts
import { defineCollection, z } from 'astro:content';
const blog = defineCollection({
schema: ({ image }) => z.object({
title: z.string(),
hero: z.object({
src: image(),
alt: z.string().min(1, 'alt text is required'),
decorative: z.boolean().default(false),
}).optional(),
}),
});
// In template: <Image src={hero.src} alt={hero.decorative ? '' : hero.alt} role={hero.decorative ? 'presentation' : undefined} /> Astro's <ViewTransitions /> component performs client-side navigation without a full page reload, which means screen readers do not announce the new page title and focus typically remains on the old navigation link. Users of NVDA, JAWS, or VoiceOver have no audible indication that the page changed.
Listen for the 'astro:after-swap' event in a small client script, update document.title explicitly if needed, move focus to the main landmark or the new h1, and announce the new page title through an ARIA live region. The Astro docs recommend exactly this pattern for accessible view transitions.
<!-- src/layouts/Base.astro -->
---
import { ViewTransitions } from 'astro:transitions';
---
<html lang="en">
<head>
<title>{title}</title>
<ViewTransitions />
</head>
<body>
<slot />
</body>
</html> <!-- src/layouts/Base.astro -->
---
import { ViewTransitions } from 'astro:transitions';
---
<html lang="en">
<head>
<title>{title}</title>
<ViewTransitions />
</head>
<body>
<div id="route-announcer" aria-live="assertive" aria-atomic="true" class="sr-only"></div>
<main id="main" tabindex="-1"><slot /></main>
<script>
document.addEventListener('astro:after-swap', () => {
const announcer = document.getElementById('route-announcer');
if (announcer) announcer.textContent = document.title;
const main = document.getElementById('main');
if (main) main.focus();
});
</script>
</body>
</html> Components rendered with client:only are not pre-rendered on the server. When they mount they can steal or drop focus, and the brief period before hydration shows an empty skeleton that is invisible to screen readers. Modals, comboboxes, and search widgets built this way often fail WCAG 2.4.3 Focus Order.
Prefer client:visible or client:idle over client:only so that the component ships server-rendered HTML first. Implement proper focus management (focus trap on open, return focus to trigger on close) inside the component, and provide a server-rendered fallback inside <Fragment slot="fallback"> so screen reader users see meaningful content before hydration.
---
import SearchDialog from '../components/SearchDialog.jsx';
---
<button>Search</button>
<SearchDialog client:only="react" /> ---
import SearchDialog from '../components/SearchDialog.jsx';
---
<button id="search-trigger" aria-haspopup="dialog">Search</button>
<SearchDialog client:visible>
<Fragment slot="fallback">
<noscript><p><a href="/search">Search (no JavaScript)</a></p></noscript>
</Fragment>
</SearchDialog>
<!-- Ensure SearchDialog traps focus when open and restores it to #search-trigger on close. --> Astro content collections with MDX let authors write arbitrary heading levels in prose. Many templates wrap rendered content inside an h1 from the layout, so an author who starts the MDX file with an h1 of their own produces two h1s, and an author who starts with h3 skips levels. Screen reader heading navigation becomes useless.
Pick one convention: either the layout owns the h1 (authors start at h2) or the MDX owns the h1 (layout uses h2 or nothing). Enforce this with a remark/rehype plugin in astro.config.mjs that normalizes heading levels, or with a content collection transform that validates the MDX tree and throws at build time. Use the built-in rendered <Content /> output rather than hand-rolling markdown.
Astro has a first-class <Image /> component in astro:assets, but many projects still use raw <img> tags inside .astro and MDX files, or use <Image /> without supplying responsive widths. Images without intrinsic width and height cause layout shift, which WCAG 1.4.10 Reflow effectively prohibits, and oversized images on mobile devices fail 1.4.4 Resize Text when users zoom.
Replace <img> with the <Image /> or <Picture /> component from astro:assets everywhere you control the source file. Pass explicit width and height derived from the source image and use the widths and sizes props for responsive layouts. The component automatically emits the correct dimensions, modern formats (AVIF, WebP), and lazy loading attributes.
---
import heroUrl from '../assets/hero.jpg';
---
<img src={heroUrl} alt="Team photo" /> ---
import { Image } from 'astro:assets';
import hero from '../assets/hero.jpg';
---
<Image src={hero} alt="Team photo" widths={[480, 960, 1440]} sizes="(max-width: 768px) 100vw, 1440px" format="avif" loading="lazy" /> Astro 4.0+ ships built-in i18n routing via astro.config.mjs, and many projects serve /en/, /de/, /es/ routes from a single layout. Developers often hardcode <html lang="en"> in the base layout. Screen readers then read German or Spanish content with an English speech synthesizer, producing incomprehensible audio.
Import getCurrentLocale from astro:i18n in your base layout and set <html lang={getCurrentLocale()}> dynamically. For inline content in a different language (quotes, names) wrap the text in <span lang="...">. Verify every locale by viewing page source and confirming the html element's lang attribute matches the actual content language.
---
// src/layouts/Base.astro
const { title } = Astro.props;
---
<!DOCTYPE html>
<html lang="en">
<head><title>{title}</title></head>
<body><slot /></body>
</html> ---
// src/layouts/Base.astro
import { getLocaleByPath } from 'astro:i18n';
const lang = getLocaleByPath(Astro.url.pathname) ?? 'en';
const { title } = Astro.props;
---
<!DOCTYPE html>
<html lang={lang} dir={lang === 'ar' || lang === 'he' ? 'rtl' : 'ltr'}>
<head><title>{title}</title></head>
<body><slot /></body>
</html> Astro starter templates frequently ship a dark mode toggle that picks theme-agnostic Tailwind or custom CSS variable values. Developers test contrast in light mode but not dark, leaving dark-mode text that fails the 4.5:1 ratio. The toggle button itself often lacks an accessible name and aria-pressed state.
Audit both light and dark palettes with a contrast checker like WebAIM Contrast Checker. Respect prefers-color-scheme by default and persist the user preference in localStorage only when they actively toggle. Give the toggle an aria-label and aria-pressed attribute reflecting current state, and announce changes through an ARIA live region if the theme switch affects announcements.
Astro-Specific Tips
- Add @axe-core/playwright to your test suite and run it against representative pages during astro build. Integrate with CI using GitHub Actions so every pull request blocks on new accessibility regressions.
- Install the eslint-plugin-jsx-a11y and eslint-plugin-astro packages. The jsx-a11y plugin catches common issues in React and Preact islands, and the Astro plugin adds rules that flag missing lang attributes and untrusted raw HTML in .astro files.
- Use the rehype-accessible-emojis plugin if you render emojis inside MDX content. It wraps emojis in <span role="img" aria-label="..."> so screen readers announce them meaningfully.
- Prefer client:visible over client:load for interactive islands. client:visible defers hydration until the component scrolls into view, reducing initial JavaScript execution on the main thread and keeping assistive tech responsive on page load.
- Set astro.config.mjs compressHTML: true and trailingSlash: 'always' (or 'never') consistently. Inconsistent trailing slashes cause redirects that can confuse screen readers with extra page-load announcements.
Recommended Tools
axe DevTools for Astro
Browser extension that audits the rendered HTML output of your Astro site, including hydrated islands. Run it against the preview build (astro preview) as well as astro dev to catch issues that appear only after view transitions and client hydration.
eslint-plugin-jsx-a11y
Static analysis of your React, Preact, and SolidJS islands for accessibility issues like missing alt text, invalid ARIA, and inaccessible event handlers. Pairs with eslint-plugin-astro for .astro-specific rules.
Astro View Transitions docs
Official documentation that covers the astro:after-swap event and accessible focus management patterns. Especially useful for adding route announcers and returning focus to a landmark after client-side navigation.
Further Reading
Other CMS Checklists
Get our free accessibility toolkit
We're building a simple accessibility checker for non-developers. Join the waitlist for early access and a free EAA compliance checklist.
No spam. Unsubscribe anytime.