Build Accessible Websites with Storyblok and Astro
Storyblok is the first headless CMS that works for developers & marketers alike.
With the European Accessibility Act (EAA) coming into effect on June 28, 2025, the time to act on accessibility (A11y) is now. Let's explore how Storyblok can help you improve your site, benefiting you and your visitors.
This tutorial focuses on pairing Storyblok with Astro to build an accessible website. We briefly discuss the what and why, then provide practical tips for working with a headless CMS and ready-to-implement code samples.
You can find all the code samples and content schema from this tutorial in a dedicated GitHub repository.
Accessibility-first development
A headless CMS lets you decouple the presentation from the content. This separation of concerns gives developers complete control over the presentation layer, which is a huge benefit when it comes to accessibility.
Both Astro and Storyblok empower accessibility-first development:
- Component-based architecture: Build modular, accessible, easy-to-test, and maintain components.
- Semantic HTML: Instead of reinventing the web with
div
s, mark up pages with meaningful elements.<header>
,<nav>
,<article>
,<aside>
,<footer>
, and other landmarks save you time and effort, and interactive elements—<button>
,<dialog>
,<details>
, and others—incorporate accessibility, focus management, and performance out of the box. - ARIA attributes: Implement ARIA (Accessible Rich Internet Applications) attributes to provide additional context and meaning for screen reader users.
Why should you care?
The EAA requires organizations and service providers to comply with the Web Content Accessibility Guidelines (WCAG) version 2.2. The latest iteration of this standard has 13 guidelines, organized under four principles: perceivable, operable, understandable, and robust. Each guideline has testable success criteria. Compliance with the law means meeting these criteria.
Despite being around since 1999, digital accessibility is still perceived as a nice-to-have. The WebAIM Million reports that 94.8% of the top million homepages fail their users, forcing them to browse a web of unreadable text, indiscernible visuals, faulty forms, empty links and buttons, and more.
Contrary to what you may think, accessibility isn't just a question of legal sticks and business carrots. Even if neither pushes you to prioritize accessibility, and you believe that moral arguments are only relevant out of office, do it for yourself: building accessible UIs makes your life as a developer much easier.
Keep reading to learn how Storyblok helps you do that.
In the CMS
Even before you write a single line of code, Storyblok's UI helps you guide and educate the editorial team and minimize human error.
Block and field names and descriptions
When you design the site's schema, give blocks and fields meaningful names that hint at their functionality. Add a description of how to use a field and explain why it's necessary—for example, alt
text in media assets, heading hierarchies, meaningful link text, etc.
Learn how to use a mix of UI and programmatic logic to provide editors with great UX without compromising accessibility.
Adapt the rich text editor
The rich text editor lets you control what editors can do and limit access to potentially inaccessible options.
Navigate to the Block library, select a block that contains a Richtext field, and scroll down to the Customize toolbar items checkbox. From there, configure which items you want to allow. Consider limiting heading levels and leaving Text Color, Highlight, and Cell color unchecked to prevent problematic color combinations.
You can also uncheck Allow links to be open in a new tab, a practice that might disorient visitors if not implemented properly.
Lastly, if you have nestable blocks that can be embedded in a rich text editor, ensure these will not cause accessibility issues. Check the Allow only specific components to be inserted option and pick which blocks are allowed.
Handle images
Most visual assets displayed using the HTML img
element must have an alternative text description in the alt
attribute.
Not sure which images should have a description? Use WAI's "alt Decision Tree".
To ensure editors provide one when they upload new assets, navigate to Settings > Asset Library > Default metadata fields and define it as a required field.
To hide decorative images from screen readers, add the relevant condition in your code to allow an empty alt
attribute.
Try our AI alt text generator, but remember: AI-generated text descriptions are a starting point.
Control the semantic structure
Create individual components for landmarks and limit who can modify them. For example, only specific users or roles can modify sections like <header>
, <nav>
, and <footer>
.
You can set permissions on several levels and limit which blocks can be used within each content type or nestable block. Use block permissions to grant access to specific blocks or hide them from specified user roles.
In Astro
Color contrast
Until contrast-color()
gains wider support among browsers, it's the designer's job to ensure sufficient color contrast and the developer's job to enforce it.
To help editors comply with WCAG contrast ratio guidelines, limit their choices to predefined swatches with Storyblok's Palette app. Install it, follow the instructions to configure the color values in the UI, and adapt the relevant Astro component.
You can use Datasources to define a set of color tokens that will be available across blocks, instead of individual fields. This is an effective way to recreate your design system inside Storyblok. Explore Storyblok Demo Space for a live example.
Below is an example of how you can modify Storyblok's feature
block (included in every Blueprint Starter space) to allow editors to pick a sufficiently contrasting background color:
---
import { storyblokEditable } from '@storyblok/astro';
const { blok } = Astro.props;
const backgroundColor = blok.color || 'transparent';
const { value } = backgroundColor;
---
<div {…storyblokEditable(blok)} class="feature" style={{ "background-color": value }}>
<p>{blok.name}</p>
</div>
We create a backgroundColor
variable to hold the hexadecimal color values provided in the block schema output. Then, we use object destructuring to pull the value
property and call the string in our CSS. Thanks to Astro's support for inline styles, we can set the correct background color to each feature
block the editor has added.

Heading level alerts
It's quite common for editors to use HTML heading tags for styling purposes. To avoid such misuse and ensure usability among users of assistive technology, be proactive: help editors fix the problem in Storyblok's Visual Editor, and alert them when headings are in the wrong order.
You can adapt this code to alert editors about other accessibility issues they can solve, like empty links.
First, create a directory named A11yChecks
in your Astro project's src/components
. In it, create two files: a11yChecks.js
and InvalidHeadingWarning.astro
.
export function findMultipleH1s() {
const h1s = Array.from(document.querySelectorAll('h1'));
return h1s.slice(1);
}
export function findSkippedHeadingLevels() {
const allHeadings = Array.from(
document.querySelectorAll('h1, h2, h3, h4, h5, h6'),
);
let previousHeadingLevel = 6;
return allHeadings.filter((heading) => {
const headingLevel = Number(heading.tagName.slice(1));
const result = headingLevel > previousHeadingLevel + 1;
previousHeadingLevel = headingLevel;
return result;
});
}
The findMultipleH1s
function searches for all h1
elements and creates an array of all matching nodes in the DOM, except the first h1
.
The findSkippedHeadingLevels
function searches for all heading elements and creates an array of all matching nodes. Then, it checks the heading level—from 1 to 6—and calculates whether it's lower than its predecessor in the DOM.
Let's use these in an Astro component. Start by registering InvalidHeadingWarning.astro
in your astro.config.mjs
file. Then, import it into the default layout file.
To avoid displaying errors in production, load it conditionally with an environment flag. This way, it renders only in preview.
Here's an abbreviated example:
---
import InvalidHeadingWarning from '../components/A11yChecks/InvalidHeadingWarning.astro';
const displayInvalidHeadingWarning = import.meta.env.PUBLIC_EDITOR_WARNINGS_ENABLED === "1";
---
<!doctype html>
<html lang="en">
<head></head>
<body>
{displayInvalidHeadingWarning && <InvalidHeadingWarning />}
<slot />
</body>
</html>
Finally, update the contents of InvalidHeadingWarning.astro
:
// 1. Use an `<output>` HTML element to display the alert.
<output data-invalid-headings-notice hidden class="warning"><svg viewBox="0 0 15 13" aria-hidden="true" height="24" width="24"><path fill="#FBCE41" fill-rule="evenodd" d="M6.49.52a1 1 0 0 1 1.736 0l6.277 10.984A1 1 0 0 1 13.635 13H1.08a1 1 0 0 1--1.496L6.49.519ZM6.358 5a1 1 0 1 1 2 0v2a1 1 0 1 1-2 0V5Zm1 4a1 1 0 1 0 0 2 1 1 0 0 0 0-2Z" clip-rule="evenodd"></path></svg>Fix the heading order</output>
<script>
import { findMultipleH1s, findSkippedHeadingLevels } from './a11yChecks';
const invalidHeadings = document.querySelector(
'[data-invalid-headings-notice]',
);
// 2. If there are multiple `h1` elements or skipped heading levels, unhide the top alert and display a notice next to each affected heading.
if (invalidHeadings) {
const multipleH1s = findMultipleH1s();
const skippedHeadingLevels = findSkippedHeadingLevels();
if (multipleH1s.length > 0 || skippedHeadingLevels.length > 0) {
invalidHeadings.removeAttribute('hidden');
}
const warning = (HTMLHeadingElement, message) => {
HTMLHeadingElement.insertAdjacentHTML(
'afterbegin',
`<mark class="warning">⚠️ ${message}️:</mark>`,
);
};
multipleH1s.forEach((HTMLHeadingElement) =>
warning(HTMLHeadingElement, 'H1 already exists on this page'),
);
skippedHeadingLevels.forEach((HTMLHeadingElement) =>
warning(HTMLHeadingElement, 'You skipped a heading level'),
);
}
</script>
// 3. Style the top and inline alerts
<style is:global>
output[data-invalid-headings-notice]:not([hidden]) {
display: inline-flex;
gap: 1em;
position: fixed;
left: 0;
padding: 10px;
color: marktext;
}
.warning {
background-color: #fef5d9;
border: 1px solid #feebb3;
opacity: 0.9;
border-radius: 0 8px 8px 0;
font-size: initial;
font-family: ui-serif, system-ui, serif;
letter-spacing: -0.05ch;
}
mark {
padding: 5px;
font-weight: initial;
}
:is(h1, h2, h3, h4, h5, h6):has(> mark) {
display: flex;
gap: 0.5ch;
align-items: center;
flex-wrap: wrap;
}
</style>
Let's unpack the code:
- We're using the HTML
output
element to display the alert. This provides a valuable accessibility benefit: since browsers associate it with anaria-live
region, assistive technology announces the contents. - If multiple
h1
elements are present on the page or heading levels were skipped, unhide the global alert at the top, and loop through the headings to display the respective notices next to each affected element.
This is what it looks like in the Visual Editor:

Top navigation
Navigation elements are the foundation of every site, but making them usable for diverse users isn't always a straightforward task. Here are a few typical requirements:
- Let assistive technology users skip the navigation and quickly reach relevant content.
- Provide an accessible name for each navigation element and mark the current page.
- Support mobile and narrower screens.
Let's learn how to accomplish all these and create a responsive and accessible navigation without client-side JavaScript:
- Add a so-called skip-link.
- Implement
aria-label
andaria-current
attributes. - Use the native HTML
popover
attribute to create a "hamburger menu".
We can do all this in a single Astro component, which we'll build bit by bit.
A Storyblok navigation block
Let's start in the CMS:
Open the Block library and create two new blocks:
- A content type block named
site_settings
with a Blocks field type namedheader_nav
. - A nested block named
nav_item
with a Link field type namedLink
.
Open the site_settings
block, select the header_nav
field, and check the Allow only specific components to be inserted option. Then, select the nav_item
component from the list.
This ensures that the header navigation includes only navigation items (links). Any other block is not allowed.
Open the Content section and create a new story named Site settings
. Select the site_settings
content type you created before.
Now, add links to any pages you'd like in the Visual Editor.
An Astro header component
Back in your code editor, create a Header.astro
file inside your src/components
directory, and paste the following code:
---
import { useStoryblokApi } from '@storyblok/astro';
const storyblokApi = useStoryblokApi();
const { data } = await storyblokApi.get('cdn/stories/site_settings', {
version: 'draft',
resolve_links: 'url',
});
const headerNav = data?.story?.content?.header_nav;
const currentPath = Astro.url.pathname;
---
<a href="#main-content">Skip to content</a>
<header>
<a href="/"><img src="<https://a.storyblok.com/f/325704/187x30/2d6101b247/brand-new-day-logo.svg>" alt="Brand New Day" width="180" height="30" aria-label="Click to navigate to the homepage"></a>
<nav aria-label="Primary">
<ul>
{headerNav.map((nav_item) =>
<li><a href={`/${nav_item.link.cached_url}`} aria-current={currentPath.includes(`/${nav_item.link.cached_url}`) ? "page" : null}>{nav_item.link.story.name}</a></li>
)}
</ul>
</nav>
</header>
This might feel overwhelming, so let's understand what's happening:
Get the navigation links
We fetch the site_settings
story and use the resolve_links
parameter to get the URLs of the stories we linked inside the nav_item
blocks.
Since we're only interested in the header_nav
field, we attach it to a variable we can loop through later.
The last thing we need is a way to check if the current page's slug matches the URL set in the nav_item
, and update the value of the aria-current
attribute accordingly. We do that with the pathname
property available via Astro's Astro.url
object.
Add a skip-link
The first element in the DOM is the hard-coded skip-link. Don't forget to add a matching id="main-content"
to the <main>
element in the template.
The skip link must be before the <nav>
element so people can skip it. It can also be the first element in the <body>
.
Next, we have the <nav>
element with its accessible name. Then, we loop through the headerMenu
object to get the data inside each nav_item
block.
Mark the current page
To dynamically add the aria-current
attribute, we use the includes()
method and check if the currentPath
is the same as the link. If it is, we set the "page"
value. Otherwise, we do nothing.
Start your development server and visit the site to ensure the navigation displays correctly, the skip-link functions as intended, and each anchor gets an ARIA attribute when you visit the page.
Mobile navigation
Now it's time to create the mobile navigation. Replace the code within the <header>
element with the following:
// The rest of the code ...
<header>
<a href="/"><img src="<https://a.storyblok.com/f/325704/187x30/2d6101b247/brand-new-day-logo.svg>" alt="Brand New Day" width="180" height="30" aria-label="Navigate to the homepage"></a>
<button popovertarget="nav" popovertargetaction="show" aria-label="Open navigation">
<svg width="30" height="30" viewBox="-100 -100 200 200" aria-hidden="true"><path stroke-width="15" d="M-45-45h90M-45 0h90m-90 45h90"/></svg>
</button>
<nav aria-label="Primary">
<ul>
<ul popover id="nav">
<li><button popovertarget="nav" popovertargetaction="hide" aria-label="Close navigation"><svg width="30" height="30" aria-hidden="true"><path stroke-width="3" d="M18 6 6 18M6 6l12 12"/></svg></button></li>
{headerNav.map((nav_item) =>
<li><a href={`/${nav_item.link.cached_url}`} aria-current={currentPath.includes(`/${nav_item.link.cached_url}`) ? "page" : null}>{nav_item.link.story.name}</a></li>
)}
</ul>
</nav>
</header>
There are two additions here:
- The "show" and "hide"
<button>
elements. - The
popover
attribute on the unordered list (ul
).
The popover
attribute, available in all evergreen browsers since April 2024, is part of the Popover API. Learning about all available features is worthwhile.
Unlike the old "CSS checkbox hack" or client-side JavaScript, the combination of native HTML elements—the <button>
and the popover
—provides both functionality and accessibility benefits out of the box:
- Full keyboard support
- Focus and state management
- Built-in
aria-expanded
state (indicate if a control is expanded or collapsed) andaria-details
relationship (provide additional information) - A semantic
group
role
CSS does the rest: animating the navigation "drawer" appearance and disappearance, dimming the background, and displaying the narrow and wider-screen versions.
Style the navigation
Finally, let's style the whole component with a mobile-first approach.
Paste this snippet below the HTML (after the closing </header>
):
<style>
/* 1. Hide the skip-link until it receives keyboard focus */
a[href="#main-content"]:not(:focus-visible) {
overflow: hidden;
white-space:nowrap;
inline-size: 0;
block-size: 0;
inset: 0;
position: fixed;
transform: translateY(-10em);
}
a[href="#main-content"] {
transform: translateY(0em);
padding: 0.3em;
}
header {
display: grid;
grid-template-columns: 1fr 2fr;
border-block-end: 1px solid;
padding-block-end: 1rem;
}
/* 2. Display the hamburger button */
nav {
display: flex;
justify-content: end;
gap: 1rem;
& button {
background: none;
border: none;
cursor: pointer;
}
& ul {
list-style-type: "";
}
& li > button {
position: fixed;
top: 1.1rem;
right: 0.8rem;
}
}
button[popovertarget="nav"] svg {
stroke: currentcolor;
stroke-linecap: round;
}
/* 3. Hide the navigation area until it gets activated */
[popover]:popover-open {
translate: 0 0;
opacity: 1;
}
[popover] {
inline-size: 250px;
block-size: 100svh;
left: auto;
border: none;
box-shadow: 0 0 5px currentcolor;
opacity: 0;
translate: 250px 0;
}
@starting-style {
[popover]:popover-open {
translate: 250px 0;
opacity: 0;
}
}
[popover]::backdrop {
background-color: hsl(0 0 0 / 0);
}
[popover]:popover-open::backdrop {
background-color: hsl(0 0 0 / 0.1);
}
@starting-style {
[popover]:popover-open::backdrop {
background-color: hsl(0 0 0 / 0);
}
}
/* 4. Style the active link */
[aria-current] {
font-weight: bolder;
}
/* 5. Enable the animations */
@media (prefers-reduced-motion: no-preference) {
a[href="#main-content"]:not(:focus-visible) {
transition: transform 0.2s ease-in-out;
}
[popover] {
transition: all 0.5s allow-discrete;
}
[popover]::backdrop {
transition: all 0.5s allow-discrete;
}
}
/* 6. Hide the hamburger button on wider screens */
@media (min-width: 35rem) {
nav button {
display: none;
}
nav ul {
display: contents;
}
}
</style>
If you start your development server and visit the site, you should see a standard horizontal navigation. To experiment with the mobile view, resize your browser window or use the dev tools panel.

As you might have noticed, we've taken advantage of accessibility-focused media queries to respect users' preferences. We wrapped the animation in a media query to ensure it activates only when users' devices are not explicitly blocking this functionality.
Tools and resources
The most important thing to remember is that accessibility, like any software development work, is an ongoing journey. There's always something to improve, and there are lots of tools that can help you navigate this landscape with confidence.
Expand your knowledge
- Learn Accessibility | web.dev
- Screen Reader Basics: VoiceOver on Mac
- Checklist - The A11Y Project
- How to use Chrome, Firefox, and Safari DevTools panels for accessibility checks
Always be testing
Run automated tests to catch some of the more obvious issues. However, it's important to remember that neither browser-based nor CI/CD tools provide full coverage and cannot replace manual testing.
- Accessibility Insights
- IBM Equal Access Checker
- Axe DevTools
- WAVE Web Accessibility Evaluation Tools
- ARC Toolkit
- Lighthouse and Unlighthouse
For quick tests during development, consider a tool like Sa11y or even good old bookmarklets.
Astro
- Lint your Astro project in development or as part of your CI/CD with eslint-plugin-jsx-a11y
- Explore the Accessible Astro Components UI library.
Takeaways
As we've hopefully demonstrated, integrating Storyblok with Astro opens the door to interesting solutions to common accessibility issues.
Features like reusable, modular components, support for design systems, and granular control over how and what editors can access help you build accessible, performant sites that guarantee a better user experience for everyone.
All you need to do is be empathetic (or at least practice some constructive selfishness).
You can find all the code samples and content schema from this tutorial in a dedicated GitHub repository. Want more tutorials on accessibility? Open an issue or let us know on Discord.
Technical advisors:
- Josefine Schaefer, Accessibility Engineer
- Angelika Cathor, Senior Frontend Engineer / Website Team