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.
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.
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.
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:
| Criterion | Level | What it means for a Django app |
|---|---|---|
| 2.4.11 Focus Not Obscured (Minimum) | AA | A focused control must not be completely hidden behind a sticky header, cookie banner or chat widget. |
| 2.4.13 Focus Appearance | AAA | The focus indicator has a minimum size and contrast. |
| 2.5.7 Dragging Movements | AA | Sortable lists, sliders and kanban boards need a single-pointer alternative such as move-up/move-down buttons. |
| 2.5.8 Target Size (Minimum) | AA | Pointer 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 Help | A | If a help link, contact details or chat appear on several pages, they appear in the same relative place. |
| 3.3.7 Redundant Entry | A | Do not ask for the same information twice in one process — prefill it or offer "same as billing address". |
| 3.3.8 Accessible Authentication (Minimum) | AA | Login 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.
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:
autocomplete attributes. autocomplete="email", "street-address", "cc-number" — this is an actual WCAG 2.1 criterion and it helps everybody.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.
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.
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:
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.inputmode="numeric" is simpler and better.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:
<header>, <nav>, <main>, <footer> — screen readers jump between them. Exactly one <main> per page.<h1>, no skipped levels. Heading structure is how non-visual users skim.lang on <html> — and with i18n, use {{ LANGUAGE_CODE }} rather than hardcoding, or the page is read aloud in the wrong accent.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.
Unplug your mouse and use your own site for five minutes. It is the fastest audit available, and it finds things no scanner reports.
outline: none without an equally clear replacement — this is the most common accessibility regression introduced by designers..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.
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.
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.
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.
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 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.aria-label, or visually hidden text inside the button. An empty button is announced as "button"..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..card or .badge as a button. If it is clickable, it contains a real <button> or <a>.The same handful of rule ids account for most failures on Django sites, and each has a typical Django-shaped cause:
| axe rule | Usual cause | Fix |
|---|---|---|
label | A 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-contrast | Brand colours, .text-warning on white, text over hero images. | Adjust Sass variables; add an overlay behind text on images. |
button-name | Icon-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-name | A logo link containing only an image with empty alt. | Give the image alt text naming the destination, such as the site name. |
html-has-lang | A 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, region | Content 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-order | Reusable 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.
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.