Bootstrap Advanced

Accessibility for Django Applications: WCAG 2.2, Semantic Templates, and Testing with axe

Accessibility is mostly template work you can automate the checking of. Fix forms and errors, build components keyboard users can operate, wire axe into CI, and understand which rules a machine can never verify.

KC Ramo · DjangoZen Team Sep 05, 2026 19 min read 114 views

Most accessibility failures on Django sites are not exotic. They are a form field without a label, an error message the screen reader never announces, a modal that traps nobody and a button that is a <div>. All of it is template work, most of it is checkable automatically, and the parts that are not are a short list you can review by hand.

The four principles, briefly

WCAG organises everything under four ideas: content must be perceivable (you can sense it), operable (you can use it without a mouse), understandable (predictable and clearly labelled) and robust (assistive technology can interpret it). AA is the level referenced by most legislation, including the European Accessibility Act, and is achievable for an ordinary Django application without redesigning it.

What WCAG 2.2 actually added

WCAG 2.2 is backwards compatible with 2.1: everything that passed 2.1 AA still matters, and a handful of new success criteria sit on top. One criterion was removed rather than added — 4.1.1 Parsing is now obsolete, because modern browsers and assistive technology no longer depend on perfectly well-formed markup. The additions are the ones worth knowing by number, because audit reports will cite them that way:

CriterionLevelWhat it means for a Django app
2.4.11 Focus Not Obscured (Minimum)AAA focused control must not be completely hidden behind a sticky header, cookie banner or chat widget.
2.4.13 Focus AppearanceAAAThe focus indicator has a minimum size and contrast.
2.5.7 Dragging MovementsAASortable lists, sliders and kanban boards need a single-pointer alternative such as move-up/move-down buttons.
2.5.8 Target Size (Minimum)AAPointer targets are at least 24 by 24 CSS pixels, or spaced so a 24px circle around each does not overlap its neighbours.
3.2.6 Consistent HelpAIf a help link, contact details or chat appear on several pages, they appear in the same relative place.
3.3.7 Redundant EntryADo not ask for the same information twice in one process — prefill it or offer "same as billing address".
3.3.8 Accessible Authentication (Minimum)AALogin must not depend on a cognitive test such as memorising or transcribing, unless an alternative exists. Allowing paste and password managers satisfies it.

For most server-rendered Django sites the expensive ones are 2.5.8 (dense icon toolbars and table action links) and 3.3.8 (custom login flows and CAPTCHAs). The others are usually template-level fixes. Harmonised standards used for procurement and legal compliance in Europe, such as EN 301 549, have historically lagged one WCAG version behind; building to 2.2 AA means you meet the older baseline and are ready when the reference moves.

Forms: where Django can help or hurt

Django renders forms for you, which means one template fix propagates everywhere. Start with the label-and-error relationship, because that is the single biggest win.

{% for field in form %}
  <div class="mb-3">
    <label for="{{ field.id_for_label }}" class="form-label">
      {{ field.label }}
      {% if field.field.required %}
        <span aria-hidden="true">*</span>
        <span class="visually-hidden">(required)</span>
      {% endif %}
    </label>

    {{ field }}

    {% if field.help_text %}
      <div id="{{ field.auto_id }}_help" class="form-text">{{ field.help_text }}</div>
    {% endif %}

    {% if field.errors %}
      <div id="{{ field.auto_id }}_error" class="invalid-feedback d-block" role="alert">
        {{ field.errors|join:" " }}
      </div>
    {% endif %}
  </div>
{% endfor %}

Then connect them in the form class, so the markup and the widget agree:

class CheckoutForm(forms.Form):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        for name, field in self.fields.items():
            described = []
            if field.help_text:
                described.append(f"id_{name}_help")
            if self.errors.get(name):
                described.append(f"id_{name}_error")
                field.widget.attrs["aria-invalid"] = "true"
            if described:
                field.widget.attrs["aria-describedby"] = " ".join(described)

What this buys: a screen reader user tabbing into a field hears the label, the help text and the error together, instead of hearing "edit text" and having to hunt for why the form was rejected.

Three more form rules worth memorising:

  • Never signal errors with colour alone. A red border is invisible to a colour-blind user and to a screen reader. Text plus icon plus colour.
  • Move focus to the error summary after a failed submit, and make it a real heading with links to each bad field.
  • Use autocomplete attributes. autocomplete="email", "street-address", "cc-number" — this is an actual WCAG 2.1 criterion and it helps everybody.

Making the ids robust to prefixes and formsets

The form class above builds ids as id_{name}_help, which matches the template only while the form uses the default auto_id and no prefix. Put the same form in a formset, or instantiate it with prefix="shipping", and the widget's id becomes id_shipping-street while aria-describedby still points at id_street_help — an element that does not exist. Screen readers silently ignore dangling references, so nothing crashes; the user just loses the description. Derive the ids from the bound field instead, and do the wiring after validation rather than inside __init__, where reading self.errors forces a full clean before subclasses have finished setting up:

class AccessibleFormMixin:
    # Wire aria-describedby / aria-invalid from the real bound-field ids.

    def full_clean(self):
        super().full_clean()
        self._wire_aria()

    def _wire_aria(self):
        for name, field in self.fields.items():
            auto_id = self[name].auto_id
            if not auto_id:
                continue
            described = []
            if field.help_text:
                described.append(f"{auto_id}_help")
            if name in self.errors:
                described.append(f"{auto_id}_error")
                field.widget.attrs["aria-invalid"] = "true"
            else:
                field.widget.attrs.pop("aria-invalid", None)
            if described:
                field.widget.attrs["aria-describedby"] = " ".join(described)


class ShippingForm(AccessibleFormMixin, forms.Form):
    street = forms.CharField(
        help_text="House number and street name.",
        widget=forms.TextInput(attrs={"autocomplete": "street-address"}),
    )
    postcode = forms.CharField(
        widget=forms.TextInput(attrs={"autocomplete": "postal-code"}),
    )

Unbound forms still need the help-text link, so call self._wire_aria() at the end of __init__ as well if your unbound forms carry help text. Note the pop in the else branch: widgets are copied per form instance, but being explicit keeps a re-validated form from announcing a stale error.

If you render fields with Django's own templates instead of a hand-written loop — {{ field.as_field_group }} or the default form rendering introduced in Django 5.0 — be aware that Django writes the help text with an id ending in _helptext and adds aria-describedby itself when the widget does not already have one. Pick one convention. Mixing the hand-rolled _help suffix with Django's _helptext template is the most common way this wiring breaks after an upgrade.

An error summary that receives focus

The "move focus to the error summary" rule above deserves a concrete template, because the details matter. The summary is a region with a heading, it lists each error as a link to the offending field, and it has tabindex="-1" so script can focus it without adding it to the tab order:

{% if form.errors %}
  <div id="error-summary" class="alert alert-danger" role="region" tabindex="-1"
       aria-labelledby="error-summary-title">
    <h2 id="error-summary-title" class="h5">There is a problem</h2>
    <ul>
      {% for error in form.non_field_errors %}<li>{{ error }}</li>{% endfor %}
      {% for field in form %}{% for error in field.errors %}
        <li><a href="#{{ field.id_for_label }}">{{ field.label }}: {{ error }}</a></li>
      {% endfor %}{% endfor %}
    </ul>
  </div>
{% endif %}
const summary = document.getElementById("error-summary");
if (summary) summary.focus();

Two companion touches: prefix the page <title> with "Error: " when the form is invalid, so the first thing announced on reload explains why the user is back on the same page; and keep the summary above the form, not in a toast that disappears. A role="alert" element that is already in the HTML when the page loads is not reliably announced by every screen reader — moving focus to the summary is the dependable mechanism, and the per-field role="alert" is a bonus for in-page validation.

Authentication forms and WCAG 2.2

Criteria 3.3.7 and 3.3.8 land squarely on login, signup and password reset. Django's built-in AuthenticationForm already marks the username and password widgets with autocomplete="username" and autocomplete="current-password"; custom forms frequently drop those attributes when they are rewritten. The checklist for auth flows:

  • Never block paste into password or confirmation fields. Pasting from a password manager is the accessible path for many users, and blocking it is an explicit 3.3.8 failure.
  • Use autocomplete="new-password" on signup and password-change fields so managers offer to generate one, and autocomplete="one-time-code" on TOTP or SMS code inputs.
  • Split one-time-code inputs cautiously. Six separate single-digit boxes break paste and autofill unless you handle both; one input with inputmode="numeric" is simpler and better.
  • Do not rely on a puzzle CAPTCHA alone. Rate limiting, honeypot fields and server-side risk scoring protect the form without asking the user to solve anything.
  • Carry data forward. If the signup step collected an email, the verification step should not ask for it again (3.3.7).

Semantic HTML does the work for free

Every ARIA attribute you add is a chance to be wrong. The native element is already correct:

<!-- broken: not focusable, no keyboard activation, no role -->
<div class="btn" onclick="save()">Save</div>

<!-- correct -->
<button type="button" class="btn btn-primary">Save</button>

Alongside that, five structural habits cover a surprising amount of WCAG:

  • Landmarks. <header>, <nav>, <main>, <footer> — screen readers jump between them. Exactly one <main> per page.
  • One <h1>, no skipped levels. Heading structure is how non-visual users skim.
  • A skip link as the first focusable element, visible on focus.
  • lang on <html> — and with i18n, use {{ LANGUAGE_CODE }} rather than hardcoding, or the page is read aloud in the wrong accent.
  • Link text that stands alone. "Read more" repeated twelve times is useless in a link list; "Read more about pricing" is not.

A base template that gets the structure right once

All five habits belong in base.html, where every page inherits them. A compact version that uses Bootstrap classes and Django's i18n tags:

{% load i18n %}
{% get_current_language as LANGUAGE_CODE %}
<!doctype html>
<html lang="{{ LANGUAGE_CODE }}">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>{% if form.errors %}{% translate "Error" %}: {% endif %}{% block title %}{% endblock %} | myproject</title>
</head>
<body>
  <a class="visually-hidden-focusable" href="#main">{% translate "Skip to main content" %}</a>
  <header>
    <nav aria-label="{% translate 'Main' %}">
      {% url 'pricing' as pricing_url %}
      <a href="{{ pricing_url }}"
         {% if request.path == pricing_url %}aria-current="page"{% endif %}>
        {% translate "Pricing" %}
      </a>
    </nav>
  </header>
  <main id="main" tabindex="-1">{% block content %}{% endblock %}</main>
  <footer>{% block footer %}{% endblock %}</footer>
</body>
</html>

A few details are doing real work here. Bootstrap's .visually-hidden-focusable keeps the skip link hidden until it receives focus. tabindex="-1" on <main> lets the skip link move focus reliably rather than only scrolling. aria-current="page" tells a screen reader which navigation item is the current page — the equivalent of the "active" styling sighted users get. When a page has more than one <nav>, give each an aria-label so the landmark list reads "Main navigation, Footer navigation" instead of "navigation, navigation". The {% block title %} matters too: unique, descriptive page titles are their own success criterion (2.4.2), and they are the first thing announced on every page load.

Keyboard and focus

Unplug your mouse and use your own site for five minutes. It is the fastest audit available, and it finds things no scanner reports.

  • Everything interactive is reachable with Tab, in an order that matches the visual layout.
  • Focus is always visible. Never outline: none without an equally clear replacement — this is the most common accessibility regression introduced by designers.
  • Modals trap focus while open, close on Escape, and return focus to the element that opened them.
  • Nothing is mouse-only — hover menus need keyboard equivalents, drag-and-drop needs a fallback.
.btn:focus-visible {
  outline: 3px solid #1a73e8;
  outline-offset: 2px;
}

:focus-visible shows the ring for keyboard users and not for mouse clicks — the compromise that ends the designer-versus-accessibility argument.

Sticky headers and focus that disappears

Criterion 2.4.11 is the one most Bootstrap sites fail without noticing. A .sticky-top navbar or a cookie banner fixed to the bottom of the viewport covers the element that has just received focus when the browser scrolls it into view. One line of CSS fixes the common case by telling the browser how much space to leave:

html {
  scroll-padding-top: 5rem;    /* height of the sticky navbar, plus a margin */
  scroll-padding-bottom: 4rem; /* height of a fixed cookie or chat bar */
}

.table .btn-sm,
.pagination .page-link {
  min-width: 24px;             /* WCAG 2.5.8 target size */
  min-height: 24px;
}

The same block handles 2.5.8: Bootstrap's normal buttons already exceed 24 pixels, but .btn-sm icon buttons in dense tables, close buttons in custom components and inline pagination links on small screens are the usual offenders. Tab through a long form with the sticky header present, and do it again at 200% zoom — the header gets proportionally taller and covers more.

Announcing changes that happen without a page load

HTMX, fetch and WebSockets update the page silently. A sighted user sees the row disappear; a screen reader user gets nothing. Live regions fix this.

<div id="announcer" aria-live="polite" aria-atomic="true" class="visually-hidden"></div>
document.body.addEventListener("htmx:afterSwap", (e) => {
  const msg = e.detail.target.dataset.announce;
  if (msg) document.getElementById("announcer").textContent = msg;
});

Use polite almost always — it waits for a pause. Reserve assertive for genuine interruptions like a session about to expire; it cuts the user off mid-sentence, and overusing it is worse than silence.

Also move focus deliberately after a swap. If clicking "Edit" replaces a panel, focus should land in the new panel — otherwise the keyboard user is still at the old position, which no longer exists.

Testing: automate the 40%, review the rest

Automated tools reliably catch missing alt text, missing labels, contrast failures, duplicate ids and invalid ARIA. That is roughly a third to a half of real issues — worth automating precisely because those failures are boring and constant.

import pytest
from axe_playwright_python.sync_playwright import Axe

@pytest.mark.parametrize("path", ["/", "/tutorials/", "/checkout/", "/account/"])
def test_no_accessibility_violations(page, live_server, path):
    page.goto(f"{live_server.url}{path}")
    results = Axe().run(page)
    serious = [v for v in results.response["violations"]
               if v["impact"] in ("critical", "serious")]
    assert not serious, "\n".join(
        f"{v['id']}: {v['help']} ({len(v['nodes'])} nodes)" for v in serious
    )

Fail on critical and serious first; treat moderate as a backlog. A test that fails on everything on day one gets disabled by the third developer who hits it.

What no scanner can judge: whether alt text is meaningful, whether the reading order makes sense, whether an error message explains what to do, whether a custom widget is actually usable. Those need a person, once per release, with the keyboard and a screen reader — VoiceOver or NVDA for an hour teaches more than any checklist.

Testing states, not just URLs

A page load tests the happy path. Most real failures live in states a crawler never reaches: the form after a failed submit, the open dialog, the expanded menu, the logged-in dashboard. Drive the page into each state with Playwright, then run axe again:

import os
import pytest
from axe_playwright_python.sync_playwright import Axe
from django.conf import settings

os.environ.setdefault("DJANGO_ALLOW_ASYNC_UNSAFE", "true")  # in conftest.py


def serious_violations(page):
    results = Axe().run(page)
    return [v for v in results.response["violations"]
            if v["impact"] in ("critical", "serious")]


@pytest.fixture
def logged_in_page(page, live_server, client, django_user_model):
    user = django_user_model.objects.create_user("deploy", password="unused-in-test")
    client.force_login(user)
    page.context.add_cookies([{
        "name": settings.SESSION_COOKIE_NAME,
        "value": client.cookies[settings.SESSION_COOKIE_NAME].value,
        "url": live_server.url,
    }])
    return page


def test_checkout_errors_are_accessible(logged_in_page, live_server):
    page = logged_in_page
    page.goto(f"{live_server.url}/checkout/")
    page.get_by_role("button", name="Place order").click()

    # the error summary exists and has focus
    assert page.evaluate("document.activeElement.id") == "error-summary"
    assert not serious_violations(page)


def test_delete_dialog_is_accessible(logged_in_page, live_server):
    page = logged_in_page
    page.goto(f"{live_server.url}/projects/")
    page.get_by_role("button", name="Delete project").first.click()
    assert page.get_by_role("dialog").is_visible()
    assert not serious_violations(page)
    page.keyboard.press("Escape")
    assert not page.get_by_role("dialog").is_visible()

The DJANGO_ALLOW_ASYNC_UNSAFE line is needed because Playwright's sync API runs an event loop in the test thread, and Django's async-safety check then refuses ORM calls made from fixtures. It is acceptable in a test settings context and must never be set in production. Note also that get_by_role is itself an accessibility assertion: if the test cannot find "a button named Place order", neither can a screen reader user, which makes role-based locators a free second layer of checking.

Bootstrap-specific notes

Bootstrap 5 gives you accessible components if you use them as documented:

  • .visually-hidden for text meant only for screen readers. Never display: none, which hides it from them too.
  • Icon-only buttons need an accessible name — aria-label, or visually hidden text inside the button. An empty button is announced as "button".
  • Check your custom colours. Bootstrap's default .text-muted only just passes (about 4.7:1 on white, and less on a light grey background), and contextual colours such as .text-warning and .text-info fall well below 4.5:1 when used for text. Contrast is the single most common automated failure on themed sites.
  • Do not use .card or .badge as a button. If it is clickable, it contains a real <button> or <a>.

Troubleshooting common axe failures in Django projects

The same handful of rule ids account for most failures on Django sites, and each has a typical Django-shaped cause:

axe ruleUsual causeFix
labelA widget rendered with {{ field }} outside the shared field template, or a search box in the navbar with only a placeholder.Render through the shared template; for standalone inputs add a <label class="visually-hidden">.
color-contrastBrand colours, .text-warning on white, text over hero images.Adjust Sass variables; add an overlay behind text on images.
button-nameIcon-only buttons from an icon font or SVG with no text.Add aria-label or visually hidden text; mark the icon aria-hidden="true".
link-nameA logo link containing only an image with empty alt.Give the image alt text naming the destination, such as the site name.
html-has-langA second base template for emails, errors or print views that was never updated.Search every template containing <html; include the custom 404.html and 500.html.
landmark-one-main, regionContent placed outside <main> by a block override, or a cookie banner in no landmark.Keep all page content in blocks inside <main>; put banners in a labelled region.
heading-orderReusable includes with hard-coded <h4> used on pages with different nesting.Pass the heading level into the include, or style with classes such as .h5 on the correct element.

Two failure modes are not in any rule list. First, the Django admin is a separate UI with its own templates; it has had considerable accessibility work in recent releases, but customised admin templates and third-party admin themes should be tested like any other page if staff depend on them. Second, error pages rendered with DEBUG = False use your 404.html and 500.html, which often extend nothing and miss the language, title and landmarks — add them to the axe path list by requesting a URL that does not exist.

A realistic order of work

  1. Run axe on your five busiest pages and fix critical issues.
  2. Fix the shared form template once — it corrects every form you have.
  3. Add the skip link, landmarks, and a visible focus style.
  4. Do the keyboard-only walkthrough of your main flow.
  5. Put axe in CI so it stays fixed.
  6. Book one manual review with a screen reader per release.

Two things are worth saying plainly. This work benefits far more people than it is usually credited for — keyboard users, people on poor connections, anyone on a phone in bright sun. And in the EU it is increasingly a legal requirement for commercial services rather than a nice-to-have, which means it is cheaper to build in now than to retrofit under a deadline.