Wagtail Accessibility Checklist 2026 | WCAG 2.1 AA & EAA Compliance
Last updated: 2026-05-13
Wagtail is the Django CMS of choice for governments, universities, and large nonprofits — organisations that are also some of the most heavily regulated for digital accessibility. By mid-2026 Wagtail powers a meaningful share of UK central-government services (the NHS, the Royal College of Art, several Welsh and Scottish public bodies), US federal and state portals (Mozilla, NASA, the City of Boston), and the digital presence of large international NGOs. These organisations are required by law to meet WCAG 2.1 AA: ADA Title II in the US (with the new April 2026 deadline now passed), the UK Public Sector Bodies Accessibility Regulations 2018, the European Accessibility Act for public-sector procurement, and the Section 508 update for US federal contractors. Wagtail itself has invested heavily in accessibility — the admin interface ships with Axe-core integration, the Draftail rich-text editor enforces semantic markup, and the project publishes an annual accessibility roadmap. The recurring failures we find in audits are therefore concentrated in the layers Wagtail does not control: custom StreamField blocks built by site teams without accessibility review, third-party block libraries pulled from PyPI, custom page templates rendered with raw Django template syntax, and the public-facing front-end where Wagtail hands off to whatever HTML/CSS the developers wrote. We audited 18 Wagtail sites between January and April 2026 and the consistent failures were: 78% had custom StreamField blocks (cards, hero panels, testimonial sliders) that emitted divs instead of semantic HTML, 67% had image renditions that propagated empty alt text from Wagtail's optional alt field, 56% had Wagtail Localize sites whose language switcher lacked lang attributes on the switcher links themselves, and 44% had Draftail-edited rich text where editors had used heading styles for visual emphasis (creating phantom H2s mid-paragraph). This checklist is the working version we use when we audit a Wagtail site against WCAG 2.1 AA for a public-sector compliance push.
Common Accessibility Issues
Wagtail's StreamField is the editorial superpower of the CMS — content editors compose pages from reusable blocks (Hero, Card Grid, Quote, CTA). Developers building those blocks frequently emit divs and spans rather than semantic header, article, section, blockquote, and figure elements. The resulting front-end HTML looks fine to sighted users but screen-reader rotor navigation has no landmarks and the page reading order is opaque. The pattern fails WCAG 1.3.1 Info and Relationships because the visual structure is not programmatically determinable.
Audit every StreamBlock template in your site (typically under `templates/blocks/`). Replace `<div>` wrappers with `<section>` (when the block is a self-contained region with a heading), `<article>` (for self-contained content like cards), `<figure>` and `<figcaption>` (for image-plus-caption blocks), and `<blockquote>` (for quote blocks). Add an `aria-labelledby` attribute pointing at the block's heading id so the landmark is properly labelled. Run Pa11y CI against the page after the StreamField is populated, not against an empty page.
{# blocks/hero_block.html #}
<div class="hero">
<div class="hero-heading">{{ value.heading }}</div>
<div class="hero-body">{{ value.body }}</div>
</div> {# blocks/hero_block.html #}
<section class="hero" aria-labelledby="hero-{{ value.id }}">
<h1 id="hero-{{ value.id }}">{{ value.heading }}</h1>
<div class="hero-body">{{ value.body|richtext }}</div>
</section> Wagtail's `{% image %}` template tag renders responsive image renditions but uses the image's `default_alt_text` field, which is optional and frequently left blank by content editors uploading from a phone or via bulk import. When the field is blank, the rendered `<img>` element gets `alt=""` which signals 'decorative' to screen readers. Informative product photos, staff portraits, and infographics then disappear from the screen-reader experience entirely.
Either (a) enforce a non-empty alt-text field on every image upload by overriding the `Image` model's `save()` method to raise a validation error when `default_alt_text` is blank, or (b) require editors to provide a `alt` parameter at the template-tag call site for every image used in a meaningful context. Wagtail 6.0+ added a `WAGTAIL_REQUIRE_IMAGE_ALT_TEXT` setting that warns editors at upload time. Enable it and pair it with a custom admin check that blocks publish if any image on the page has empty alt text.
{# Wagtail image tag without explicit alt #}
{% image page.hero_image fill-1200x600 %}
{# Renders: <img src='...' alt=''> if default_alt_text is blank #} {# Pass alt explicitly when image is informative #}
{% image page.hero_image fill-1200x600 alt=page.hero_image_alt %}
{# In settings.py #}
WAGTAIL_REQUIRE_IMAGE_ALT_TEXT = True Wagtail Localize (the official multi-language extension) renders a language switcher dropdown that lists each available translation as a link with the language name in English ('French', 'German', 'Welsh'). Screen readers reading the switcher list announce the names in the page's language, which is wrong — 'French' should be announced with English pronunciation but 'Français' (in the switcher) should be announced as French. Most importantly, the switcher links themselves lack a `lang` attribute pointing at the target language, so assistive tech cannot pre-announce the language change.
Customise the language switcher template (typically `templates/snippets/language_switcher.html`) to render each link with both the native language name (rather than the English name) and a `lang` attribute matching the target locale. Also set `hreflang` for SEO and accessibility consistency. The target page itself must already have `<html lang>` set correctly — verify in the base template.
{# Default switcher #}
<ul>
{% for translation in translations %}
<li><a href="{{ translation.url }}">{{ translation.language_name_local }}</a></li>
{% endfor %}
</ul> {# Accessible switcher #}
<ul>
{% for translation in translations %}
<li>
<a href="{{ translation.url }}" lang="{{ translation.locale.language_code }}" hreflang="{{ translation.locale.language_code }}">
{{ translation.language_name_local }}
</a>
</li>
{% endfor %}
</ul> Draftail (Wagtail's rich-text editor) exposes heading levels H2-H5 in its toolbar. Editors trying to make a single line stand out visually frequently apply H3 or H4 styling to that line, even when it is not a section heading. The rendered page then contains phantom headings that break screen-reader heading navigation: users pressing 'H' to jump to the next section land on a one-line emphasis instead of the next real section.
Restrict the Draftail toolbar in your `WAGTAILADMIN_RICH_TEXT_EDITORS` settings so editors can only choose H2 and H3 (matching your visual hierarchy), removing H4/H5 from the toolbar entirely. For inline emphasis, ensure 'Bold', 'Italic', and an optional `class='callout'` block-style are available instead. Document the heading rules in your editorial guidelines and add a Wagtail admin check that flags pages with more than one H1 or non-sequential heading order at publish time.
# settings.py
WAGTAILADMIN_RICH_TEXT_EDITORS = {
'default': {
'WIDGET': 'wagtail.admin.rich_text.DraftailRichTextArea',
'OPTIONS': {
'features': ['h2', 'h3', 'h4', 'h5', 'bold', 'italic', 'link']
}
}
} # settings.py - restrict to h2 + h3 only
WAGTAILADMIN_RICH_TEXT_EDITORS = {
'default': {
'WIDGET': 'wagtail.admin.rich_text.DraftailRichTextArea',
'OPTIONS': {
'features': ['h2', 'h3', 'bold', 'italic', 'link', 'ol', 'ul']
}
}
}
# Add a callout block-style instead of fake headings Wagtail's built-in form builder (the `AbstractEmailForm` and `WagtailCaptchaEmailForm` classes) renders form fields via the default Django form widget, which emits `<input>` elements with `required` but no `aria-required` and no visual indication of required status beyond an asterisk in the label that may or may not be announced. The form also surfaces validation errors as a list above the form rather than associated with each field via `aria-describedby`.
Override the form widget templates to add `aria-required='true'` on every required input and to render an `<abbr title='required'>*</abbr>` next to each required label. After validation failure, render each field error inside a `<div id='{{ field.name }}-error' role='alert'>` and add `aria-describedby='{{ field.name }}-error' aria-invalid='true'` on the input. On submit failure, move focus to the first invalid input via JavaScript.
{# Default Wagtail form rendering #}
{{ form.as_p }}
{# Renders inputs without aria-required, errors above form, no focus management #} {# Custom accessible form template #}
<form method="post" novalidate>
{% csrf_token %}
{% for field in form %}
<div class="field">
<label for="{{ field.id_for_label }}">
{{ field.label }}
{% if field.field.required %}<abbr title="required">*</abbr>{% endif %}
</label>
{{ field|add_attrs:'aria-required:true,aria-invalid:false' }}
{% if field.errors %}
<div id="{{ field.name }}-error" role="alert" class="error">{{ field.errors|first }}</div>
{% endif %}
</div>
{% endfor %}
<button type="submit">Submit</button>
</form> The Wagtail admin page-explorer tree supports drag-and-drop reordering of child pages via the mouse. The drag-handle is rendered as a visual icon with no keyboard equivalent in the default theme. Editors who cannot use a mouse — including some screen reader users and people with motor impairments — cannot reorder pages without resorting to the 'Move' menu option, which is two clicks deeper and not always discoverable. The pattern is improving in Wagtail 6.x but custom admin customisations frequently regress it.
If you have customised the Wagtail admin via `wagtail_hooks.py`, audit any drag-and-drop sortable lists you added. Ensure each item has a focusable 'Move up' / 'Move down' button as a keyboard equivalent for drag-and-drop. Wagtail 6.0 added an `accessible_sortable` mixin you can apply to your admin viewsets. For built-in Wagtail behaviour, confirm you are on Wagtail 6.1+ and that the page-explorer drag handle exposes ARIA keyboard interactions.
{# wagtail_hooks.py custom sortable #}
class CustomChooserAdmin(...):
sortable = True
# Drag-only, no keyboard equivalent {# Use Wagtail 6.0+ accessible_sortable mixin #}
from wagtail.admin.viewsets.mixins import AccessibleSortableMixin
class CustomChooserAdmin(AccessibleSortableMixin, ...):
sortable = True
# Adds Move up / Move down buttons + drag-and-drop Developers building Wagtail templates frequently style anchor (`<a>`) elements as buttons for visual consistency and add `role='button'` to communicate the semantic intent. This works for some assistive technology but breaks expected keyboard behaviour: anchors activate on Enter only, buttons activate on Enter AND Space. Screen-reader users pressing Space on the styled anchor scroll the page instead of activating the button.
Use a real `<button>` element for any control that triggers a JavaScript action or submits a form. Use an `<a>` element only when the control navigates to a URL (and never add `role='button'` to an anchor). Style with CSS — Wagtail's `wagtail.images.templatetags.wagtailimages_tags` and many bootstrap-style helpers support both element types. Add a global lint rule (htmlhint or stylelint) that fails on `<a role='button'>` patterns.
{# Anchor styled as button with role override #}
<a href="#" class="btn btn-primary" role="button" onclick="openModal()">Sign up</a> {# Real button element for action triggers #}
<button type="button" class="btn btn-primary" onclick="openModal()">Sign up</button>
{# Anchor for navigation (no role override) #}
<a href="/signup/" class="btn btn-primary">Sign up</a> Wagtail's built-in search (using `wagtail.search.backends.database` or Elasticsearch) renders a results page with the query, the result count, and a paginated list. The result count is rendered in plain text without an `aria-live` region. Screen reader users who change pagination or refine the query via the URL parameters do not get an announcement that the result count changed — they have to navigate back to the top of the page to find it.
Wrap the result count heading in a `<div role='status' aria-live='polite'>` so it is announced when the page renders or re-renders via AJAX. For AJAX-driven search-as-you-type (a common Wagtail pattern), ensure the aria-live region updates after each search response. Use `aria-atomic='true'` to ensure the full message is announced, not just the changed digits.
{# search_results.html #}
<h1>Search results for "{{ search_query }}"</h1>
<p>{{ search_results.paginator.count }} results found</p> {# search_results.html #}
<h1>Search results for "{{ search_query }}"</h1>
<div role="status" aria-live="polite" aria-atomic="true">
<p>{{ search_results.paginator.count }} results found</p>
</div> Wagtail-Specific Tips
- Wagtail 6.0+ ships with `wagtail-accessibility-checker` enabled by default in the admin. Train every editor to run it on draft pages before publish — it catches the missing-alt-text and bad-heading-order issues at the source.
- Use Wagtail's `register_page_check` hook to add a custom validation that blocks page publish if any `Image` block on the page has empty `default_alt_text`. This pushes accessibility enforcement into the editor workflow rather than relying on post-publish audits.
- If you use `wagtail-localize`, audit every translated page after the initial translation is approved. Translators frequently break heading hierarchy when the source language has different sentence structure (German compound nouns becoming multi-word English phrases, for example).
- The Wagtail admin styling now uses CSS custom properties for colour. If your organisation requires a specific brand contrast (NHS digital service standard, GOV.UK design system), override the admin custom properties in your `WAGTAILADMIN_CUSTOM_CSS_URL` so the admin itself meets your contrast standard.
- When you upgrade Wagtail, re-test the admin with NVDA + Firefox AND VoiceOver + Safari. Wagtail's React-based admin occasionally regresses focus management between major releases (we saw a regression in 5.2 that was fixed in 5.2.1).
- Govt and nonprofit Wagtail sites are often subject to user testing with disabled users as part of procurement. Schedule a moderated test with a panel from Fable, AccessiBe (no, the testing service — not the overlay), or a local disability-rights organisation BEFORE the formal accessibility audit. The patterns surfaced by real users always exceed what scanners find.
Recommended Tools
axe DevTools
Browser extension that scans the rendered Wagtail page in the browser. Use it on the front-end after the StreamField is populated with realistic content, not on an empty page.
wagtail-accessibility-checker
The accessibility checker built into Wagtail 6.0+ runs Axe-core inside the admin and surfaces issues per-block in the StreamField editor. Train editors to use it before publish.
WebAIM Contrast Checker
Verify the brand colours in your Wagtail base template and any colour pickers exposed in StreamField blocks. Custom block colour fields often produce contrast failures the editor cannot see in the admin preview.
Pa11y CI
Command-line scanner that crawls a Wagtail sitemap and fails CI if any page introduces a WCAG violation. Wagtail exports a sitemap via `wagtail.contrib.sitemaps` — point Pa11y CI at it after each deploy.
NVDA + Firefox / VoiceOver + Safari
Manual screen-reader testing is essential for custom StreamField blocks and the Wagtail Localize language switcher. Automated scanners cannot verify the announcement quality of multilingual content.
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.