This is a preview of the Storyblok Website with Draft Content

From content strategy to code, JoyConf brings the Storyblok community together - Register Now!

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.

hint:

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 divs, 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:

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 ColorHighlight, 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.

learn:

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.

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.

learn:

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:

src/storyblok/Feature.astro
---
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.


Working with the Color Palette App in Storyblok: The Feature block includes a "Color" field that lets editors select one of the predefined background colors.

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.

hint:

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.

src/components/A11yChecks/a11yChecks.js
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:

src/layouts/Layout.astro
---
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:

src/components/A11yChecks/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:

  1. We're using the HTML output element to display the alert. This provides a valuable accessibility benefit: since browsers associate it with an aria-live region, assistive technology announces the contents.
  2. 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:

Enabling the heading alert script: The Visual Editor includes multiple alerts notifying the editor about potential accessibility issues.

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 and aria-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 named header_nav.
  • A nested block named nav_item with a Link field type named Link.

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:

src/components/Header.astro
---
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:

We fetch the site_settings story and use the resolve_links parameter to get the URLs of the stories we linked inside the nav_itemblocks.

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.

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.

required:

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:

src/components/Header.astro
// 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:

  1. The "show" and "hide" <button> elements.
  2. The popover attribute on the unordered list (ul).
learn:

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:

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>):

src/components/Header.astro

<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.

Mobile functionality of the popover "hamburger" navigation element: Closed on the left and opened on the right.

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

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.

hint:

For quick tests during development, consider a tool like Sa11y or even good old bookmarklets.

Astro

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).

hint:

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