Storyblok Accessibility Checklist 2026 | WCAG 2.1 AA & EAA Compliance
Last updated: 2026-05-13
Storyblok has become the visual headless CMS of choice for mid-market and enterprise teams in Europe — Germany, the Nordics, the UK, and increasingly Spain and Italy. By mid-2026 it powers the websites of well-known European brands across e-commerce (Tessa, Bonprix, On Running), media (RTL, BVG public-transit Berlin), and SaaS (Personio, Adverity), as well as a long tail of agencies serving regulated industries. That European concentration means Storyblok sites are under direct pressure from the European Accessibility Act, which came into force on 28 June 2025 and now mandates WCAG 2.1 AA conformance for any business with more than ten employees or revenue above two million euros offering products and services to consumers in the EU. Storyblok itself is not the failure point. The Visual Editor is keyboard-accessible, the admin meets WCAG 2.1 AA, and the platform publishes accessibility guidance for developers. The failures we find are in the developer-built layers: custom Bloks (Storyblok's reusable component primitives) shipped without semantic HTML, image renditions consumed via the Storyblok Image Service without alt-text propagation, language-routing patterns in Next.js or Nuxt that ship the wrong `` attribute, and the use of Storyblok's `richtext` field rendered with default front-end resolvers that strip semantic structure. We audited 14 Storyblok-powered sites in early 2026 and the consistent failures were: 85% had custom Bloks emitting divs instead of landmarks, 71% had image renditions without alt text propagation from the Storyblok asset's `alt` field, 64% had multi-language sites with incorrect `` attributes, and 50% had richtext fields rendering ordered/unordered lists as plain divs because the developer omitted the `marks_default_resolver`. This checklist is the working version we use when scoping a Storyblok accessibility audit for an EAA compliance push.
Common Accessibility Issues
Storyblok's Bloks are reusable component primitives that editors drag into the Visual Editor. Front-end developers building those Bloks (in React, Vue, Svelte, or Astro) frequently wrap the output in a `<div>` and call it done. The resulting page has no `<header>`, `<main>`, `<nav>`, `<section>`, or `<article>` elements — only divs. Screen-reader rotor and landmark navigation finds nothing to navigate to, and the reading order is opaque.
Audit every Blok component in your repository (typically under `components/bloks/` or `components/storyblok/`). Replace the outer `<div>` with the appropriate semantic element: `<section aria-labelledby>` for blocks with a heading, `<article>` for self-contained content, `<figure>` for image-plus-caption, `<header>` and `<footer>` for page-level structural blocks. The Storyblok `editable` attribute (for in-place editing) works on any element, not just divs.
// components/bloks/HeroBlok.jsx
export default function HeroBlok({ blok }) {
return (
<div {...storyblokEditable(blok)} className="hero">
<div className="hero-heading">{blok.heading}</div>
<div className="hero-body">{blok.body}</div>
</div>
);
} // components/bloks/HeroBlok.jsx
export default function HeroBlok({ blok }) {
const headingId = `hero-${blok._uid}`;
return (
<section {...storyblokEditable(blok)} className="hero" aria-labelledby={headingId}>
<h1 id={headingId}>{blok.heading}</h1>
<div className="hero-body">{blok.body}</div>
</section>
);
} Storyblok assets have an optional `alt` field set in the asset library. The Storyblok Image Service (`a.storyblok.com`) returns a URL but does not embed alt text in the URL — the front-end must read the asset object's `alt` property and pass it to the `<img>` tag. Developers consuming `blok.image.filename` for the URL frequently forget to also pass `blok.image.alt`, resulting in `<img alt='' />` even when the editor filled in the alt-text field.
When rendering Storyblok images, always destructure both `filename` and `alt` from the asset object, and pass `alt` to the `<img>` or framework Image component. Set up an ESLint rule (or your framework's equivalent) that forbids `<img src={blok.image.filename}>` without `alt={blok.image.alt}` nearby. For decorative images, explicitly set `alt=''` rather than omitting the attribute. The Storyblok admin should require alt-text on upload for any image used in a content area; configure this via the Storyblok Asset Manager rules.
// Missing alt prop
<img src={blok.image.filename} className="hero-img" /> // Alt explicitly propagated
<img src={blok.image.filename} alt={blok.image.alt || ''} className="hero-img" />
// Or for Next.js Image component
<Image src={blok.image.filename} alt={blok.image.alt || ''} width={1200} height={600} /> Storyblok Spaces support folder-based or domain-based internationalisation. When a Next.js or Nuxt site is configured for multi-language routing, the `<html lang>` attribute in the root layout often stays hardcoded to `en` (or the developer's locale) even on German, French, or Italian pages. Screen readers then use the wrong pronunciation rules for the entire page, making content unintelligible to users who rely on speech output for translation. WCAG 3.1.1 requires the page's primary language to be programmatically determined.
Read the current locale from the page's route or context in your root layout and set `<html lang>` dynamically. In Next.js App Router, set `lang` on the `<html>` element in the layout function based on the `[locale]` parameter. In Nuxt 3, use `useHead({ htmlAttrs: { lang: locale.value } })`. In Astro, set `lang={Astro.currentLocale}` on the html tag.
// app/layout.tsx - hardcoded
export default function RootLayout({ children }) {
return <html lang="en"><body>{children}</body></html>;
} // app/[locale]/layout.tsx - dynamic per locale
export default function RootLayout({ children, params }) {
return <html lang={params.locale}><body>{children}</body></html>;
}
// Or in Nuxt
useHead({ htmlAttrs: { lang: locale.value } }); Storyblok's `richtext` field stores content as a JSON tree (paragraphs, lists, links, marks). The default `@storyblok/richtext` renderer outputs proper HTML but many teams write a custom renderer that flattens lists into a series of `<div>` items to apply custom styling. The result is that screen-reader users hear the items as separate paragraphs with no list semantics and no item count announcement.
Use the official `@storyblok/richtext` renderer with the default `node_resolvers` map, then style with CSS instead of changing the HTML. If you must add custom styling, override only the `attrs` of `bullet_list`, `ordered_list`, and `list_item` nodes — never replace them with `<div>`. Test by inspecting the rendered HTML for `<ul>`, `<ol>`, and `<li>` elements and by listening with NVDA or VoiceOver.
// Custom renderer that flattens lists
const customResolvers = {
bullet_list: (children) => `<div class="list">${children}</div>`,
list_item: (children) => `<div class="list-item">${children}</div>`,
}; // Use the default renderer with CSS styling
import { richTextResolver } from '@storyblok/richtext';
const resolver = richTextResolver();
const html = resolver.render(blok.body);
// Style via CSS:
// .richtext ul { list-style: disc; padding-left: 1.5rem; }
// .richtext li { margin-bottom: 0.5rem; } Storyblok's Visual Editor renders the front-end preview inside an iframe alongside the field editor. Editorial reviewers who use a screen reader to verify a page before publish must tab through the entire site navigation on every preview load. Without a skip-to-content link in the iframed preview, reviewing a single block requires dozens of unnecessary tabs.
Ensure the front-end Storyblok consumes includes a skip-to-content link as the first focusable element in the preview output. The same link benefits public users. Test inside the Visual Editor by clicking the preview iframe, pressing Tab, and verifying the first focused element is 'Skip to main content'.
// app/[locale]/layout.tsx - no skip link
<body>
<Header />
{children}
<Footer />
</body> // app/[locale]/layout.tsx with skip link
<body>
<a href="#main" className="skip-link">Skip to main content</a>
<Header />
<main id="main" tabIndex={-1}>{children}</main>
<Footer />
</body> Storyblok asset fields are often consumed as CSS background images for hero sections, parallax effects, and card backgrounds. CSS backgrounds are invisible to screen readers regardless of the asset's alt text. When the background image carries information (a photo of a product, a brand campaign image with text), that information is lost to screen-reader users.
If the background image is informative, render it as a real `<img>` element with `alt={blok.image.alt}` positioned via CSS (absolute positioning) instead of a CSS background. If the image is purely decorative, leave it as a CSS background and ensure the textual content on top conveys the full message without needing the image. Test by disabling images in the browser and confirming the page still makes sense.
// Background image with information lost
<section style={{ backgroundImage: `url(${blok.hero_image.filename})` }}>
<h1>{blok.heading}</h1>
</section> // Real img tag, positioned via CSS, accessible alt
<section className="hero-section">
<img src={blok.hero_image.filename} alt={blok.hero_image.alt || ''} className="hero-bg" />
<div className="hero-content">
<h1>{blok.heading}</h1>
</div>
</section> Storyblok Link Field Renders External Links Without Visual or Programmatic Indication
WCAG 2.4.4Storyblok's `link` field returns a URL with no metadata about whether the link is internal or external. Front-end renderers commonly render all links the same way, with no `target='_blank' rel='noopener noreferrer'` for external links and no visible 'opens in new window' indicator. Screen-reader users following links have no warning that they are about to leave the site.
In the link renderer, detect whether the URL is internal (relative or matching the site's domain) or external. For external links, add `target='_blank' rel='noopener noreferrer'`, append a visually-hidden 'opens in new window' span, and optionally show a small visible external-link icon. Use the Storyblok `linktype` property (`url`, `story`, `email`, `asset`) to distinguish behaviour.
// All links rendered the same
<a href={blok.link.url}>{blok.link_text}</a> // Detect external, signal to users and AT
const isExternal = blok.link.linktype === 'url' && !blok.link.url.startsWith(siteOrigin);
<a
href={blok.link.url}
{...(isExternal && { target: '_blank', rel: 'noopener noreferrer' })}
>
{blok.link_text}
{isExternal && <span className="sr-only"> (opens in new window)</span>}
</a> A common Storyblok component pattern is a Tabs Blok that lets editors group content into switchable panels. Many implementations render tabs as styled `<div>` elements with onClick handlers, breaking keyboard navigation (arrow keys should move between tabs, Home/End should jump to first/last). Screen readers also fail to announce the tab role and selected state.
Implement the WAI-ARIA Authoring Practices tab pattern: a parent `role='tablist'`, each tab as a `<button role='tab' aria-selected aria-controls>`, each panel as `<div role='tabpanel' aria-labelledby>`. Bind ArrowRight/ArrowLeft on the tablist for navigation, and Home/End for jumping. The React community has battle-tested implementations (Radix UI Tabs, React Aria Tabs) you can wrap in a Storyblok-aware component.
<div className="tabs">
{blok.tabs.map(tab => (
<div className="tab" onClick={() => setActive(tab.id)}>{tab.label}</div>
))}
</div> import * as Tabs from '@radix-ui/react-tabs';
<Tabs.Root defaultValue={blok.tabs[0]._uid}>
<Tabs.List aria-label="Page sections">
{blok.tabs.map(tab => (
<Tabs.Trigger key={tab._uid} value={tab._uid}>{tab.label}</Tabs.Trigger>
))}
</Tabs.List>
{blok.tabs.map(tab => (
<Tabs.Content key={tab._uid} value={tab._uid}>
{tab.content.map(b => <DynamicBlok blok={b} />)}
</Tabs.Content>
))}
</Tabs.Root> Storyblok-Specific Tips
- Configure Storyblok Asset Manager rules to require non-empty `alt` text on every image uploaded to a folder used for content (not the `decorative/` folder). This enforces the discipline at the editor workflow stage rather than in code review.
- Storyblok's `description` field on Bloks is a great place to document accessibility expectations for editors. When you create a Hero Blok, write 'This block uses the first heading on the page (H1). Use it once per page.' in the field description so editors see it inline.
- If your Storyblok site uses Next.js App Router with parallel routes for the Visual Editor preview, ensure the preview route includes the same layout (including skip link and `<html lang>`) as production. Diverging preview and production layouts is a common source of audit-vs-production drift.
- Storyblok supports webhook-triggered builds. Pair the publish webhook with a Pa11y CI run against the preview URL — if the new page introduces a WCAG violation, Pa11y CI can fail the build and post a comment back to the Storyblok story via the Storyblok Management API.
- EAA-compliant Storyblok deployments should include an accessibility statement at /accessibility/. Build it as a Storyblok story so the legal team can edit it without a developer. Include the statement URL in your Storyblok footer Blok.
- When migrating from another headless CMS to Storyblok, audit the rich-text content after import. Imports from Contentful and Sanity often lose heading levels or convert lists to plain paragraphs, requiring manual editorial cleanup before the site meets WCAG 1.3.1.
Recommended Tools
axe DevTools
Browser extension that scans the rendered Storyblok page in the browser. Run it inside the Visual Editor preview and on the production URL — they may diverge if the preview iframe uses a different layout.
WebAIM WAVE
Visual accessibility evaluator that highlights issues directly on the rendered page. Useful for showing editorial reviewers what specific Storyblok blocks are causing failures.
Pa11y CI
Command-line scanner that crawls a sitemap and fails CI if any page introduces a WCAG violation. Pair with Storyblok's publish webhook to scan preview URLs before content goes live.
WebAIM Contrast Checker
Verify brand colours configured in Storyblok colour-picker fields (often used in Hero and CTA Bloks) before they propagate site-wide. Editors picking colours rarely think about contrast.
NVDA + Firefox / VoiceOver + Safari
Manual screen-reader testing is essential for the Storyblok richtext field, custom Bloks, and the multi-language switcher. Automated scanners cannot fully verify announcement quality.
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.