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.

DjangoZen Team Sep 05, 2026 18 min read 16 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.

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.

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.

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.

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.

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 namearia-label, or visually hidden text inside the button. An empty button is announced as "button".
  • Check your custom colours. Bootstrap's default .text-muted and several contextual colours fall below 4.5:1 on white. 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>.

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.