TRENDING
Rows of identical brass-colored apartment mailboxes with small locks and name labels along an orange corridor wall
October 9, 2026
How to Prevent Broken Object Level Authorization (IDOR) in a FastAPI App
Street-level upward view of the Monetary Authority of Singapore building and neighbouring office towers under a pale sky
October 9, 2026
Singapore’s AI Guidelines Turn Independent Review Into a Question of Who Sets the Risk Rating
Cast-iron late Qing dynasty coin minting press with a large flywheel, displayed in a museum case
October 9, 2026
Attackers Hijacked the .gh, .sl and .as Country Domains and Minted HTTPS Certificates for Google
Rows of closed oak library card catalog drawers, each with a brass pull and a blank label holder
October 9, 2026
How to Encrypt PII in Python and Keep It Searchable With Blind Indexes
Close-up of a vintage Western Electric manual telephone switchboard with orange lamps, red patch cords plugged into jacks, a rotary dial and a black handset
October 9, 2026
Microsoft’s Agent Lightning v1.0 Turns Agent Training Into a Sample-Accounting Problem
09 Oct 2026
SXZ.io SXZ.io
  • Home
Search the Site
Popular Searches:
Technology Amazon AI
Recent Posts
Two orange safety relief valves on grey pressure vessels in an industrial plant
How to Add Backpressure and Load Shedding to a Python Service Before Overload Takes It Down
October 8, 2026
Yellow diamond-shaped merging traffic warning sign showing a side road joining a main road
GitHub’s Git Rebuild Turns Repository Durability and Read Scale Into Two Separate Problems
October 8, 2026
A lugworm lying on wet sand and mud at low tide
A Compromised Admin Account Put the Shai-Hulud Worm Into AI Sandbox Maker Tensorlake’s npm SDK
October 8, 2026
SXZ.io SXZ.io
  • Home

Categories

Articles 232 Posts
News 234 Posts
Learning Hub 204 Posts
Home/Learning Hub/How to Find and Fix WCAG 2.2 Accessibility Bugs With axe-core and Playwright
Learning Hub

How to Find and Fix WCAG 2.2 Accessibility Bugs With axe-core and Playwright

Build a deliberately inaccessible signup form, catch it with an automated axe-core scan, then find the keyboard trap the scanner misses and fix everything against real WCAG 2.2 success criteria.

August 19, 2026 23 Min Read
43

Automated accessibility scanners are useful, and they are not enough on their own. In this tutorial you will build a small signup form with several real, common accessibility bugs, run it through an automated scanner called axe-core, then discover that the scanner misses the single worst bug in the form: a keyboard user cannot reach the submit button at all. You will fix every issue, verify each fix with real tests, and come away knowing exactly where automated testing stops and manual testing has to start.

Table Of Content

  • What You Will Build
  • Prerequisites
  • Step 1: Set Up Your Project
  • Step 2: Build a Realistic (and Deliberately Broken) Signup Form
  • Step 3: Serve the Page and Look at It Like a Browser Does
  • Step 4: Run Your First Automated Accessibility Scan
  • Reading the axe-core Report
  • Step 5: Map Each Violation to a WCAG 2.2 Success Criterion
  • Step 6: The Bug axe-core Never Saw: Test With a Keyboard Only
  • Why Automated Scanners Cannot Catch This
  • Step 7: Look at What a Screen Reader Actually Announces
  • Step 8: Fix the Form, One Violation at a Time
  • Fix 1: Give the Page a Language
  • Fix 2: Replace placeholder-Only Inputs With Real Labels
  • Fix 3: Turn Clickable divs Into Real Buttons Inside a Real Form
  • Fix 4: Make the Contrast Fix Measurable, Not a Guess
  • Fix 5: Add a Visible Focus Indicator
  • Fix 6: Stop Relying on Color Alone for Errors
  • Fix 7: Announce Errors to Screen Readers With aria-live
  • Step 9: Re-Scan and Confirm Zero Automated Violations
  • Step 10: Re-Test With the Keyboard
  • Step 11: Verify the Full Error and Submission Flow End to End
  • A WCAG 2.2 Gotcha Even the Fixed Form Exposes: Target Size
  • Common Mistakes to Avoid
  • How to Verify Your Own Page Works End to End
  • Next Steps

WCAG stands for Web Content Accessibility Guidelines, the standard published by the World Wide Web Consortium (W3C) that defines what “accessible” means for a web page in testable terms. WCAG 2.2 is the current version, published as a W3C Recommendation on October 5, 2023, adding nine new success criteria on top of WCAG 2.1. It organizes requirements into numbered “success criteria” (for example, 1.4.3 Contrast Minimum), each rated Level A, AA, or AAA, where AA is the level most organizations target and the level this tutorial focuses on.

You do not need any prior accessibility experience to follow along. You do need to be comfortable running commands in a terminal and editing plain HTML, CSS, and JavaScript files.

What You Will Build

You will create a single self-contained HTML file for a “Create your account” signup form, deliberately written the way a busy developer might actually write it on a first pass, with no framework and no build step. Then you will:

  • Scan it automatically with axe-core, a widely used, open source accessibility testing engine maintained by Deque Systems, driven through a real Chromium browser with Playwright.
  • Walk through it using only the Tab key, the way a keyboard-only or switch-device user has to, and record exactly what happens.
  • Fix every issue found, one at a time, and re-verify each fix against the same tests.
  • Confirm the finished form passes an automated scan, is fully keyboard operable, and announces validation errors to screen readers.

Prerequisites

  • Python 3.11 or later. This tutorial was built and tested on Python 3.13.14.
  • Basic familiarity with HTML, CSS, and JavaScript. You do not need React, Vue, or any other framework; every file here is plain HTML with inline CSS and JavaScript.
  • A terminal and a text editor.
  • No prior accessibility or screen reader experience required. Every WCAG term used is defined the first time it appears.

Step 1: Set Up Your Project

Create a new folder for this project and set up a Python virtual environment. This keeps the packages you install here separate from anything else on your machine.

mkdir a11y-tutorial
cd a11y-tutorial
python -m venv venv

# Windows
venv\Scripts\activate

# macOS or Linux
source venv/bin/activate

pip install playwright
python -m playwright install chromium

Playwright is a browser automation library. Unlike simpler HTML checkers that only read your markup as text, Playwright drives a real Chromium browser, which means it can tell you what actually happens when a real rendering engine and a real keyboard interact with your page, not just what the HTML source looks like on paper. That distinction matters more than it sounds like it should, and you will see exactly why in Step 6.

python -m playwright install chromium downloads a real, standalone copy of Chromium that Playwright controls directly. This tutorial used Chromium 151.0.7922.34 and Playwright 1.62.0; expect newer point releases if you follow along later, which is fine, the behavior in this tutorial has been stable across Chromium versions for years.

Next, download axe-core itself, the open source accessibility testing engine maintained by Deque Systems. axe-core is a JavaScript library, not a Python package, so you inject it into the browser page at test time rather than importing it in Python.

curl -o axe.min.js https://cdnjs.cloudflare.com/ajax/libs/axe-core/4.10.2/axe.min.js

Gotcha: if you install axe-core through a Python wrapper package instead of pulling the file directly, check its version before you trust it. This tutorial started with a popular wrapper that bundled axe-core 4.4.3, a version released in 2022, over a year before WCAG 2.2 existed. That old engine silently has no rule for one of the criteria this tutorial specifically tests (2.5.8 Target Size). Always confirm the bundled axe.min.js reports the version you expect before you rely on its results; the fix is a one-line download of a current build, shown above.

Step 2: Build a Realistic (and Deliberately Broken) Signup Form

Create a file named step1_broken.html with the contents below. Every bug in this form is one that shows up in real production code, usually because it looks completely fine on screen with a mouse.

<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Create your account</title>
<style>
  body { font-family: Arial, sans-serif; max-width: 420px; margin: 40px auto; color: #222; }
  .card { border: 1px solid #ddd; padding: 24px; border-radius: 8px; position: relative; }
  .close { position: absolute; top: 8px; right: 8px; border: none; background: none; font-size: 14px; cursor: pointer; width: 16px; height: 16px; padding: 0; }
  input[type=text], input[type=email], input[type=password] {
    width: 100%; padding: 8px; margin-bottom: 4px; border: 1px solid #ccc; box-sizing: border-box;
    outline: none;
  }
  .hint { color: #aaa; font-size: 12px; margin-bottom: 14px; }
  .error input { border-color: red; }
  .submit-btn { background: #2563eb; color: white; padding: 10px 20px; text-align: center; cursor: pointer; border-radius: 4px; width: 100%; }
  .agree-row { display: flex; align-items: center; gap: 4px; margin: 12px 0; font-size: 13px; }
  .agree-row input[type=checkbox] { width: 10px; height: 10px; }
  #msgArea { color: red; font-size: 13px; }
</style>
</head>
<body>
<div class="card">
  <div class="close" onclick="document.querySelector('.card').style.display='none'">&times;</div>
  <h1>Create your account</h1>

  <input type="text" placeholder="Full name" id="name">
  <div class="hint">As it appears on your ID</div>

  <input type="email" placeholder="Email address" id="email">
  <input type="password" placeholder="Password" id="password">

  <div class="agree-row">
    <input type="checkbox" id="agree">
    <span>I agree to the Terms of Service</span>
  </div>

  <div id="msgArea"></div>

  <div class="submit-btn" onclick="submitForm()">Create account</div>
</div>

<script>
function submitForm() {
  const name = document.getElementById('name');
  const email = document.getElementById('email');
  const password = document.getElementById('password');
  const agree = document.getElementById('agree');
  const msgArea = document.getElementById('msgArea');

  msgArea.textContent = '';
  name.parentElement.classList.remove('error');

  if (!name.value.trim()) {
    msgArea.textContent = 'Please fill in all fields.';
    name.parentElement.classList.add('error');
    return;
  }
  if (!email.value.includes('@')) {
    msgArea.textContent = 'Please fill in all fields.';
    email.parentElement.classList.add('error');
    return;
  }
  if (password.value.length < 8) {
    msgArea.textContent = 'Please fill in all fields.';
    password.parentElement.classList.add('error');
    return;
  }
  if (!agree.checked) {
    msgArea.textContent = 'Please fill in all fields.';
    return;
  }
  msgArea.style.color = 'green';
  msgArea.textContent = 'Account created.';
}
</script>
</body>
</html>

Read through what this form actually does before you run any tooling on it:

  • The “Create account” control and the “×” close control are both <div> elements with an onclick handler, not <button> elements.
  • The name, email, and password inputs use placeholder text instead of a <label>.
  • The hint text under the name field is styled color: #aaa, a light gray on a white background.
  • Every input has outline: none, removing the browser’s default focus ring, with nothing added in its place.
  • A failed validation only changes the field’s border to red and shows the single generic message “Please fill in all fields,” regardless of which field, or how many fields, are actually wrong.
  • The message area (#msgArea) is a plain <div> with no ARIA attributes, so nothing announces it when its text changes.
  • The “I agree” checkbox is styled down to 10 by 10 CSS pixels.
  • The <html> element has no lang attribute.

Step 3: Serve the Page and Look at It Like a Browser Does

Both axe-core and Playwright need to load your page over HTTP, not as a bare file:// path, so a couple of browser behaviors around focus and script loading work the way they do in production. Python’s built-in http.server module is enough for local testing.

python -m http.server 8791

Leave that running and open http://127.0.0.1:8791/step1_broken.html in a real browser. With a mouse, everything works. That is exactly the trap: the bugs you are about to find are invisible if you only ever test with a mouse and your eyes.

Step 4: Run Your First Automated Accessibility Scan

Create run_axe.py. This script starts a local web server in a background thread, loads a page with Playwright, injects axe.min.js into the live page, and runs axe’s scanner against the rendered DOM.

import sys
import json
import http.server
import socketserver
import threading
import functools
from playwright.sync_api import sync_playwright

AXE_PATH = "axe.min.js"

def serve_dir(directory, port):
    handler = functools.partial(http.server.SimpleHTTPRequestHandler, directory=directory)
    httpd = socketserver.TCPServer(("127.0.0.1", port), handler)
    thread = threading.Thread(target=httpd.serve_forever, daemon=True)
    thread.start()
    return httpd

def run_axe_on(url, axe_js):
    with sync_playwright() as p:
        browser = p.chromium.launch()
        page = browser.new_page()
        page.goto(url)
        page.wait_for_load_state("networkidle")
        page.add_script_tag(path=axe_js)
        results = page.evaluate("""async () => {
            return await axe.run(document, {
                runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa', 'wcag22aa'] }
            });
        }""")
        browser.close()
        return results

if __name__ == "__main__":
    filename = sys.argv[1] if len(sys.argv) > 1 else "step1_broken.html"
    port = 8791
    httpd = serve_dir(".", port)
    try:
        url = f"http://127.0.0.1:{port}/{filename}"
        results = run_axe_on(url, AXE_PATH)
        violations = results["violations"]
        print(f"=== axe-core scan: {filename} ===")
        print(f"Total violations: {len(violations)}")
        for v in violations:
            print(f"- [{v['impact']}] {v['id']}: {v['help']}  (nodes: {len(v['nodes'])})")
        with open(f"axe_result_{filename}.json", "w", encoding="utf-8") as f:
            json.dump(results, f, indent=2)
    finally:
        httpd.shutdown()

The runOnly option scopes the scan to rules tagged wcag2a, wcag2aa, and wcag22aa, which is axe-core’s way of saying “everything needed for WCAG 2.2 Level AA conformance.” Without it, axe also runs a large set of best-practice rules that are useful but are not, strictly, WCAG requirements, which would make the output noisier for this tutorial.

Run it against the broken form:

python run_axe.py step1_broken.html
=== axe-core scan: step1_broken.html ===
Total violations: 3
- [serious] color-contrast: Elements must meet minimum color contrast ratio thresholds  (nodes: 1)
- [serious] html-has-lang: <html> element must have a lang attribute  (nodes: 1)
- [critical] label: Form elements must have labels  (nodes: 1)

Reading the axe-core Report

This is the real, unedited output from the scan above. Three violations, not eight, even though a careful human review of the markup in Step 2 turns up considerably more than three problems. Two details are worth digging into before you fix anything, because they explain a lot about what automated tools can and cannot see.

First, the label violation lists only one node, the “I agree” checkbox, in its failureSummary:

 target: ['#agree']
 failure summary: Fix any of the following:
  Form element does not have an implicit (wrapped) <label>
  Form element does not have an explicit <label>
  aria-label attribute does not exist or is empty
  aria-labelledby attribute does not exist, references elements that do not exist or references elements that are empty
  Element has no title attribute
  Element has no placeholder attribute
  Element's default semantics were not overridden with role="none" or role="presentation"

The name, email, and password fields are not flagged, even though none of them has a real <label> either. Look closely at the last line of that failure summary: “Element has no placeholder attribute.” axe-core’s label rule treats a placeholder attribute as one of several acceptable, if weak, sources of an accessible name. Because name, email, and password all have placeholder text, they technically satisfy the rule. The checkbox has no placeholder to fall back on, so it is the only one flagged.

This is a real, important gap, not a tooling mistake. Placeholder text disappears the instant a user types a character, is frequently rendered at low contrast by default (as you will confirm yourself in Step 8), and is not read consistently by every screen reader and browser combination. It is a genuinely bad substitute for a label. WCAG’s own guidance on Success Criterion 3.3.2 Labels or Instructions and 1.3.1 Info and Relationships treats placeholder-only labeling as an anti-pattern for exactly these reasons, even though the automated label rule, reasonably, has to give it partial credit since a placeholder is at least something.

Second, notice what is not in this list at all: nothing about the “Create account” <div>, the “×” close <div>, the color-only error state, or the missing live region for validation messages. You will find all of those the hard way, starting with the next step.

Step 5: Map Each Violation to a WCAG 2.2 Success Criterion

axe-core’s rule IDs map directly to specific, numbered WCAG success criteria. Knowing the mapping means you can explain a bug in terms your team, or a client, or an auditor, will recognize.

  • color-contrast maps to 1.4.3 Contrast (Minimum), Level AA: text needs at least a 4.5:1 contrast ratio against its background (3:1 for large text, roughly 18pt or 14pt bold and up).
  • html-has-lang maps to 3.1.1 Language of Page, Level A: screen readers use the lang attribute to choose correct pronunciation rules; without it, a screen reader may read English content with the wrong accent or voice, or mispronounce it outright.
  • label maps to 4.1.2 Name, Role, Value, Level A (and overlaps with 3.3.2 Labels or Instructions, Level A): every form control needs a programmatically determinable name, not just a visual one.

axe-core’s own helpUrl field points to Deque University’s documentation for each rule, which is a fast way to look up the reasoning and fix technique for any rule ID you see in a report.

Step 6: The Bug axe-core Never Saw: Test With a Keyboard Only

Every interactive control on a web page needs to be operable from a keyboard alone. This is WCAG’s oldest and most fundamental requirement, 2.1.1 Keyboard, Level A, and it exists because not everyone can use a mouse, trackpad, or touchscreen: people with motor disabilities, people using switch-access devices, screen reader users navigating linearly, and power users all rely on the keyboard.

Create tab_walk.py, which loads a page and presses Tab repeatedly, printing exactly which element ends up focused after each press.

import sys
import http.server
import socketserver
import threading
import functools
from playwright.sync_api import sync_playwright

def serve_dir(directory, port):
    handler = functools.partial(http.server.SimpleHTTPRequestHandler, directory=directory)
    httpd = socketserver.TCPServer(("127.0.0.1", port), handler)
    thread = threading.Thread(target=httpd.serve_forever, daemon=True)
    thread.start()
    return httpd

def describe_focus(page):
    return page.evaluate("""() => {
        const el = document.activeElement;
        if (!el || el === document.body) return null;
        return {
            tag: el.tagName.toLowerCase(),
            id: el.id || null,
            cls: el.className || null,
            text: (el.textContent || '').trim().slice(0, 30)
        };
    }""")

if __name__ == "__main__":
    filename = sys.argv[1] if len(sys.argv) > 1 else "step1_broken.html"
    presses = int(sys.argv[2]) if len(sys.argv) > 2 else 8
    port = 8792
    httpd = serve_dir(".", port)
    try:
        with sync_playwright() as p:
            browser = p.chromium.launch()
            page = browser.new_page()
            page.goto(f"http://127.0.0.1:{port}/{filename}")
            page.wait_for_load_state("networkidle")
            print(f"=== Tab walk: {filename} ===")
            for i in range(1, presses + 1):
                page.keyboard.press("Tab")
                focused = describe_focus(page)
                print(f"Tab #{i}: {focused}")
            browser.close()
    finally:
        httpd.shutdown()
python tab_walk.py step1_broken.html 8
=== Tab walk: step1_broken.html ===
Tab #1: {'tag': 'input', 'id': 'name', 'cls': None, 'text': ''}
Tab #2: {'tag': 'input', 'id': 'email', 'cls': None, 'text': ''}
Tab #3: {'tag': 'input', 'id': 'password', 'cls': None, 'text': ''}
Tab #4: {'tag': 'input', 'id': 'agree', 'cls': None, 'text': ''}
Tab #5: None
Tab #6: {'tag': 'input', 'id': 'name', 'cls': None, 'text': ''}
Tab #7: {'tag': 'input', 'id': 'email', 'cls': None, 'text': ''}
Tab #8: {'tag': 'input', 'id': 'password', 'cls': None, 'text': ''}

Read this transcript carefully, because it is the most important result in this tutorial. Focus moves through name, email, password, and the agree checkbox in order, exactly as expected. Then, on Tab #5, focus lands on None: it left the document entirely (the browser moved focus to its own address bar), then wrapped back to name on Tab #6. The “Create account” button and the “×” close control were never focused at any point. A keyboard-only user can fill in this entire form and then has no way to submit it. Pressing Enter inside a text input does nothing here either, because there is no <form> element and no type="submit" button to trigger.

Why Automated Scanners Cannot Catch This

axe-core, like most automated accessibility tools, evaluates the DOM as rendered: attributes, computed styles, ARIA roles. A <div onclick="..."> is, as far as the accessibility tree is concerned, indistinguishable from a <div> that does nothing at all. There is no rule an automated tool could reasonably apply that says “flag every div with a click handler,” because plenty of divs have click handlers for legitimate, non-interactive reasons (analytics tracking, for instance), and being right about which ones are actually meant to be operable controls would require inferring developer intent, not just reading markup. This is exactly why 2.1.1 Keyboard is a criterion no automated scanner fully covers, and why a short, scripted keyboard walk like the one above belongs in your testing process alongside, not instead of, an automated scan.

Step 7: Look at What a Screen Reader Actually Announces

Playwright can also print a simplified version of the accessibility tree, the same structure a screen reader reads from, for any element on the page. Run this against the broken form’s “Create account” and close controls:

from playwright.sync_api import sync_playwright
import http.server, socketserver, threading, functools

def serve_dir(directory, port):
    handler = functools.partial(http.server.SimpleHTTPRequestHandler, directory=directory)
    httpd = socketserver.TCPServer(("127.0.0.1", port), handler)
    threading.Thread(target=httpd.serve_forever, daemon=True).start()
    return httpd

httpd = serve_dir(".", 8794)
with sync_playwright() as p:
    browser = p.chromium.launch()
    page = browser.new_page()
    page.goto("http://127.0.0.1:8794/step1_broken.html")
    page.wait_for_load_state("networkidle")
    print(page.locator(".submit-btn").aria_snapshot())
    browser.close()
httpd.shutdown()
- text: Create account

That single line is the whole story. In the accessibility tree, “Create account” is not a button, a link, or any kind of control; it is plain, inert text, the same as a paragraph of body copy. A screen reader user tabbing or reading through this page would have no indication whatsoever that it does anything. Run the same check on the fixed version later in this tutorial and you will see the difference directly.

Step 8: Fix the Form, One Violation at a Time

Now fix every issue found so far, plus the ones the automated scan missed. Each fix below maps to a specific WCAG 2.2 success criterion.

Fix 1: Give the Page a Language

Add lang="en" to the <html> tag. This satisfies 3.1.1 Language of Page and is one line:

<html lang="en">

Fix 2: Replace placeholder-Only Inputs With Real Labels

Add a visible <label>, associated by for/id, to every input, and switch the placeholder text to a separate hint element instead of the field’s only name:

<div class="field" id="nameField">
  <label for="name">Full name</label>
  <input type="text" id="name" name="name" autocomplete="name">
  <div class="hint" id="nameHint">As it appears on your ID</div>
</div>

The autocomplete attribute is a small, separate accessibility win worth adding while you are here: it satisfies 1.3.5 Identify Input Purpose (Level AA) and lets browsers and password managers correctly auto-fill the field, which particularly helps people with cognitive or motor disabilities avoid retyping information.

Fix 3: Turn Clickable divs Into Real Buttons Inside a Real Form

This is the fix for the keyboard trap from Step 6. Wrap the fields in a real <form>, and replace both <div onclick> elements with real <button> elements:

<button type="button" class="close" aria-label="Close dialog" onclick="document.querySelector('.card').style.display='none'">&times;</button>

<form id="signupForm" novalidate>
  ...
  <button type="submit" class="submit-btn">Create account</button>
</form>

Native <button> elements are keyboard operable by default (both Tab, to focus, and Enter or Space, to activate) with zero extra code, because the browser already implements 2.1.1 Keyboard and 4.1.2 Name, Role, Value for you. This is the single highest-leverage fix in this whole tutorial: it costs nothing beyond using the right HTML element, and it is also why “use semantic HTML before you reach for ARIA” is close to a universal rule in accessibility work.

The close button also gets aria-label="Close dialog". Its visible content, the “×” character, is not a meaningful name on its own: some screen readers announce it as “multiplication sign” or “times,” and a symbol alone rarely conveys “this closes the dialog” the way a sighted user infers from its position and styling. aria-label overrides the element’s text content for accessibility purposes without changing what is shown on screen, so sighted and screen reader users each get an appropriate, working name.

Fix 4: Make the Contrast Fix Measurable, Not a Guess

The hint text was color: #aaaaaa on a white background. WCAG’s contrast formula (defined in the WCAG 2 specification) is a specific, computable relative-luminance calculation, not a subjective call, so you can check any color pair yourself:

def hex_to_rgb(h):
    h = h.lstrip('#')
    return tuple(int(h[i:i+2], 16) for i in (0, 2, 4))

def luminance(rgb):
    def chan(c):
        c = c / 255.0
        return c / 12.92 if c <= 0.03928 else ((c + 0.055) / 1.055) ** 2.4
    r, g, b = rgb
    return 0.2126 * chan(r) + 0.7152 * chan(g) + 0.0722 * chan(b)

def contrast(hex1, hex2):
    l1 = luminance(hex_to_rgb(hex1)) + 0.05
    l2 = luminance(hex_to_rgb(hex2)) + 0.05
    return max(l1, l2) / min(l1, l2)

print(round(contrast('#aaaaaa', '#ffffff'), 2))  # 2.32, fails the 4.5 minimum
print(round(contrast('#595959', '#ffffff'), 2))  # 7.0, passes with margin

#aaaaaa on white measures 2.32:1, well under the 4.5:1 minimum 1.4.3 Contrast (Minimum) requires for normal-size text. Switching to #595959 gives 7.0:1, comfortably clearing even the stricter, optional AAA threshold of 7:1, so it has headroom if the design changes slightly later.

.hint { color: #595959; font-size: 12px; }

Fix 5: Add a Visible Focus Indicator

The original CSS set outline: none on every input with nothing to replace it, which fails 2.4.7 Focus Visible (Level AA): a keyboard user tabbing through the page has no way to see where they are. Use the :focus-visible pseudo-class, which modern browsers apply for keyboard focus specifically (as opposed to a mouse click), so you get a clear ring for keyboard users without adding an outline around every mouse click:

.close:focus-visible, .submit-btn:focus-visible, input:focus-visible {
  outline: 3px solid #1d4ed8;
  outline-offset: 2px;
}

Fix 6: Stop Relying on Color Alone for Errors

The broken form indicated an invalid field only by turning its border red, which fails 1.4.1 Use of Color (Level A): color blindness affects roughly 1 in 12 men and 1 in 200 women worldwide, and a red border alone is invisible information to a fully colorblind user, and easy to miss for anyone glancing quickly. The fix pairs color with a warning icon and specific text, generated per field instead of one generic message for the whole form:

.field-error {
  color: #b91c1c;
  font-size: 13px;
  display: flex;
  gap: 4px;
}
.field-error::before { content: "\26A0"; }
function setFieldError(fieldId, message) {
  const field = document.getElementById(fieldId);
  field.classList.add('error');
  let err = field.querySelector('.field-error');
  if (!err) {
    err = document.createElement('div');
    err.className = 'field-error';
    field.appendChild(err);
  }
  err.textContent = message;
  const input = field.querySelector('input');
  input.setAttribute('aria-invalid', 'true');
  input.setAttribute('aria-describedby', input.id + 'Error');
  err.id = input.id + 'Error';
}

Specific messages like “Enter a valid email address, like [email protected]” instead of “Please fill in all fields” satisfy 3.3.1 Error Identification (Level A), which requires that an error be described in text and, where possible, that the item in error be identified. aria-invalid="true" and aria-describedby connect the input programmatically to its error text, so a screen reader announces both the field’s invalid state and the specific reason when the user reaches it, not just a red box a sighted user has to notice.

Fix 7: Announce Errors to Screen Readers With aria-live

The original message area was a plain <div>: changing its text did nothing for a screen reader user, who could not see a visual change and received no announcement either, since nothing tells assistive technology to watch that element. This is where the tutorial’s own code took a wrong turn on the first pass, which is worth walking through rather than hiding.

The first version of the fix used <div id="msgArea" role="alert" aria-live="polite"></div>. That looks reasonable and would probably pass a casual review, but per MDN’s ARIA alert role reference, role="alert" already implies aria-live="assertive" and aria-atomic="true" on its own. Explicitly setting aria-live="polite" on the same element contradicts the role’s own implicit behavior, which is exactly the kind of subtle, easy-to-miss mistake that automated tools and quick visual review both tend to let through. The correct fix is simpler than the mistake: use role="alert" by itself.

<div id="msgArea" role="alert"></div>

role="alert" is intentionally “assertive”: it interrupts and announces immediately, which is appropriate here because it is only ever populated in response to a direct user action (submitting the form), never on page load. Save the more gentle aria-live="polite" pattern for background status updates a user did not just trigger, where interrupting them would be more disruptive than helpful.

Step 9: Re-Scan and Confirm Zero Automated Violations

Save all of the fixes above into a new file, step2_fixed.html. Here is the complete, final version:

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Create your account</title>
<style>
  body { font-family: Arial, sans-serif; max-width: 420px; margin: 40px auto; color: #222; }
  .card { border: 1px solid #ddd; padding: 24px; border-radius: 8px; position: relative; }
  .close {
    position: absolute; top: 8px; right: 8px; border: none; background: none;
    font-size: 16px; cursor: pointer; width: 32px; height: 32px; padding: 0;
    display: flex; align-items: center; justify-content: center;
  }
  .close:focus-visible, .submit-btn:focus-visible, input:focus-visible {
    outline: 3px solid #1d4ed8; outline-offset: 2px;
  }
  label { display: block; font-weight: bold; font-size: 14px; margin-bottom: 4px; }
  input[type=text], input[type=email], input[type=password] {
    width: 100%; padding: 8px; margin-bottom: 4px; border: 1px solid #ccc; box-sizing: border-box;
  }
  .hint { color: #595959; font-size: 12px; margin-bottom: 14px; }
  .field { margin-bottom: 14px; }
  .field.error input { border-color: #b91c1c; border-width: 2px; }
  .field-error {
    color: #b91c1c; font-size: 13px; margin-top: 4px; display: flex; gap: 4px; align-items: flex-start;
  }
  .field-error::before { content: "\26A0"; }
  .submit-btn {
    background: #2563eb; color: white; padding: 10px 20px; text-align: center;
    cursor: pointer; border-radius: 4px; width: 100%; border: none; font-size: 15px;
  }
  .agree-row { display: flex; align-items: center; gap: 8px; margin: 16px 0; font-size: 13px; padding: 6px 0; }
  .agree-row input[type=checkbox] { width: 18px; height: 18px; margin: 0; flex-shrink: 0; }
  #msgArea { font-size: 13px; margin-bottom: 12px; }
  #msgArea.success { color: #15803d; }
  #msgArea:empty { margin: 0; }
</style>
</head>
<body>
<div class="card">
  <button type="button" class="close" aria-label="Close dialog" onclick="document.querySelector('.card').style.display='none'">&times;</button>
  <h1>Create your account</h1>

  <form id="signupForm" novalidate>
    <div id="msgArea" role="alert"></div>

    <div class="field" id="nameField">
      <label for="name">Full name</label>
      <input type="text" id="name" name="name" autocomplete="name">
      <div class="hint" id="nameHint">As it appears on your ID</div>
    </div>

    <div class="field" id="emailField">
      <label for="email">Email address</label>
      <input type="email" id="email" name="email" autocomplete="email">
    </div>

    <div class="field" id="passwordField">
      <label for="password">Password</label>
      <input type="password" id="password" name="password" autocomplete="new-password">
    </div>

    <label class="agree-row" for="agree">
      <input type="checkbox" id="agree" name="agree">
      <span>I agree to the Terms of Service</span>
    </label>

    <button type="submit" class="submit-btn">Create account</button>
  </form>
</div>

<script>
const form = document.getElementById('signupForm');
const msgArea = document.getElementById('msgArea');

function setFieldError(fieldId, message) {
  const field = document.getElementById(fieldId);
  field.classList.add('error');
  let err = field.querySelector('.field-error');
  if (!err) {
    err = document.createElement('div');
    err.className = 'field-error';
    field.appendChild(err);
  }
  err.textContent = message;
  const input = field.querySelector('input');
  input.setAttribute('aria-invalid', 'true');
  input.setAttribute('aria-describedby', (input.id + 'Error'));
  err.id = input.id + 'Error';
}

function clearFieldError(fieldId) {
  const field = document.getElementById(fieldId);
  field.classList.remove('error');
  const err = field.querySelector('.field-error');
  if (err) err.remove();
  const input = field.querySelector('input');
  input.removeAttribute('aria-invalid');
  input.removeAttribute('aria-describedby');
}

form.addEventListener('submit', function (e) {
  e.preventDefault();
  msgArea.className = '';
  msgArea.textContent = '';
  ['nameField', 'emailField', 'passwordField'].forEach(clearFieldError);

  const name = document.getElementById('name');
  const email = document.getElementById('email');
  const password = document.getElementById('password');
  const agree = document.getElementById('agree');

  let firstInvalid = null;

  if (!name.value.trim()) {
    setFieldError('nameField', 'Enter your full name.');
    firstInvalid = firstInvalid || name;
  }
  if (!email.value.includes('@')) {
    setFieldError('emailField', 'Enter a valid email address, like [email protected].');
    firstInvalid = firstInvalid || email;
  }
  if (password.value.length < 8) {
    setFieldError('passwordField', 'Password must be at least 8 characters.');
    firstInvalid = firstInvalid || password;
  }

  if (firstInvalid) {
    msgArea.textContent = 'Please fix the highlighted fields below.';
    firstInvalid.focus();
    return;
  }
  if (!agree.checked) {
    msgArea.textContent = 'You must agree to the Terms of Service to continue.';
    agree.focus();
    return;
  }

  msgArea.className = 'success';
  msgArea.textContent = 'Account created.';
});
</script>
</body>
</html>

Run the same scan you ran in Step 4, against the new file:

python run_axe.py step2_fixed.html
=== axe-core scan: step2_fixed.html ===
Total violations: 0

Step 10: Re-Test With the Keyboard

An automated scan reporting zero violations is a necessary check, not a sufficient one, since Step 6 already showed a scan can report a clean result while a page is still unusable by keyboard. Re-run the exact same Tab walk from Step 6 against the fixed form:

python tab_walk.py step2_fixed.html 8
=== Tab walk: step2_fixed.html ===
Tab #1: {'tag': 'button', 'id': None, 'cls': 'close', 'text': '×'}
Tab #2: {'tag': 'input', 'id': 'name', 'cls': None, 'text': ''}
Tab #3: {'tag': 'input', 'id': 'email', 'cls': None, 'text': ''}
Tab #4: {'tag': 'input', 'id': 'password', 'cls': None, 'text': ''}
Tab #5: {'tag': 'input', 'id': 'agree', 'cls': None, 'text': ''}
Tab #6: {'tag': 'button', 'id': None, 'cls': 'submit-btn', 'text': 'Create account'}
Tab #7: None
Tab #8: {'tag': 'button', 'id': None, 'cls': 'close', 'text': '×'}

Every interactive control, the close button, all three inputs, the checkbox, and the submit button, now receives focus in a single, logical pass, and focus correctly leaves the page and wraps back to the start afterward. Re-running the accessibility-tree check from Step 7 confirms the same fix from the assistive technology side:

- button "Close dialog": ×

Compare that to - text: × from Step 7. The element is no longer invisible text; it is now a real button with a clear, spoken name.

Step 11: Verify the Full Error and Submission Flow End to End

Zero axe violations and full keyboard reachability are not the same as “the form actually works correctly for a keyboard user.” Write one more test that drives the entire flow, submitting empty, checking the error wiring, then filling in valid data and submitting again, using only keyboard events:

from playwright.sync_api import sync_playwright
import http.server, socketserver, threading, functools

def serve_dir(directory, port):
    handler = functools.partial(http.server.SimpleHTTPRequestHandler, directory=directory)
    httpd = socketserver.TCPServer(("127.0.0.1", port), handler)
    threading.Thread(target=httpd.serve_forever, daemon=True).start()
    return httpd

httpd = serve_dir(".", 8793)
with sync_playwright() as p:
    browser = p.chromium.launch()
    page = browser.new_page()
    page.goto("http://127.0.0.1:8793/step2_fixed.html")
    page.wait_for_load_state("networkidle")

    for _ in range(6):
        page.keyboard.press("Tab")
    page.keyboard.press("Enter")  # submit empty

    focused = page.evaluate("() => ({id: document.activeElement.id, tag: document.activeElement.tagName})")
    print("Focus after failed submit:", focused)

    aria = page.evaluate("""() => {
        const name = document.getElementById('name');
        return {
            ariaInvalid: name.getAttribute('aria-invalid'),
            ariaDescribedby: name.getAttribute('aria-describedby'),
            errorText: document.getElementById(name.getAttribute('aria-describedby'))?.textContent
        };
    }""")
    print("ARIA wiring:", aria)

    page.keyboard.type("Jordan Alvarez")
    page.keyboard.press("Tab")
    page.keyboard.type("[email protected]")
    page.keyboard.press("Tab")
    page.keyboard.type("correct horse battery")
    page.keyboard.press("Tab")
    page.keyboard.press("Space")  # check the agree checkbox
    page.keyboard.press("Tab")
    page.keyboard.press("Enter")  # submit valid

    final = page.evaluate("() => document.getElementById('msgArea').textContent")
    print("Final message:", final)
    browser.close()
httpd.shutdown()
Focus after failed submit: {'id': 'name', 'tag': 'INPUT'}
ARIA wiring: {'ariaInvalid': 'true', 'ariaDescribedby': 'nameError', 'errorText': 'Enter your full name.'}
Final message: Account created.

This confirms three things a static scan cannot: submitting an empty form moves focus to the first actual problem (the name field) instead of leaving a keyboard user stranded on the button they just pressed; the field is correctly marked invalid with its specific reason attached through aria-describedby; and a complete, valid submission entered entirely from the keyboard succeeds. That is the real target: not “the tool is quiet,” but “a person who can only use a keyboard can actually create an account.”

A WCAG 2.2 Gotcha Even the Fixed Form Exposes: Target Size

One of WCAG 2.2’s nine new criteria, 2.5.8 Target Size (Minimum), Level AA, requires clickable targets to be at least 24 by 24 CSS pixels, unless one of a short list of exceptions applies. The “I agree” checkbox in this tutorial is styled at 18 by 18 pixels, under that minimum. Run the axe-core scan’s full results (not just violations) against the fixed form and look specifically at the target-size rule for the checkbox, and you will find it passes anyway.

The reason is one of 2.5.8’s own built-in exceptions: “The target offset to any other target is at least 24 CSS pixels” (in plain terms, if a target has enough empty space around it that no other target sits within a 24-pixel-diameter circle centered on it, the target itself can be smaller). axe-core’s own data for this node confirms exactly that reasoning:

 "id": "target-offset",
 "message": "Target has sufficient space from its closest neighbors.
  Safe clickable space has a diameter of 24px which is at least 24px."

That is a legitimate pass under the letter of the rule, and it is also a genuinely useful thing to know rather than take on faith: a technically compliant target can still be uncomfortably small to hit precisely, especially for anyone with limited fine motor control, if the surrounding whitespace happens to save it. This tutorial’s checkbox is also wrapped in a <label> covering the whole row (checkbox plus the “I agree to the Terms of Service” text), which is not required by 2.5.8 at all, it is a plain HTML association technique, but in practice it gives the control a much larger real clickable and tappable area than the 18-pixel box alone, which is the more useful fix even when the narrower rule already technically passes.

Common Mistakes to Avoid

  • Trusting a zero-violation scan as proof of accessibility. Step 6 in this tutorial is the whole lesson: axe-core found real problems, then completely missed the single worst one. When the UK Government Digital Service deliberately built a page with 143 known accessibility failures and ran 10 automated tools against it, no single tool caught more than 41 percent, and the tools only caught 71 percent even combined, missing 29 percent between all of them. Deque’s separate analysis of over 2,000 real-world audits puts automated coverage at 57 percent by issue volume, a different measurement that tells a similar story: treat a clean report as a floor, not a finish line.
  • Using placeholder text as a label’s replacement. It weakens or removes the label the instant a user starts typing, and it commonly ships at low contrast by default.
  • Reaching for a <div> with onclick instead of a <button>. You then have to manually rebuild keyboard focus, keyboard activation, and the accessible role that a real <button> gives you automatically. It is almost never worth it.
  • Stacking ARIA attributes without checking what they already imply. This tutorial’s own first draft paired role="alert" with an explicit, contradictory aria-live="polite". When in doubt, check the ARIA specification or MDN for what a role implies before adding a live-region attribute next to it.
  • Pinning an old copy of your scanning engine. A stale axe-core build will not just be missing a few bug fixes, it can be missing entire success criteria, as this tutorial’s Step 1 gotcha showed for WCAG 2.2’s target-size rule.

How to Verify Your Own Page Works End to End

Use this checklist, the same one this tutorial’s own code went through, on any page you are testing:

  1. Run an automated scan (axe-core, or an equivalent tool) scoped to wcag2a, wcag2aa, and wcag22aa, and fix everything it reports.
  2. Unplug your mouse, or just do not touch it, and Tab through the entire page. Every interactive control should receive focus, in a sensible order, with a visible focus indicator at each stop.
  3. Trigger every error and success state using only the keyboard, and confirm focus moves somewhere useful, not nowhere and not stuck.
  4. Check any dynamic message (validation errors, toasts, save confirmations) against the ARIA live-region rules: role="alert" for things that need immediate attention, aria-live="polite" plus role="status" for background updates, never both an alert role and an explicit, conflicting aria-live value on the same element.
  5. Compute or measure your actual color contrast ratios rather than eyeballing them; a formula-based check takes a few lines of code, as shown in Step 8, and removes the guesswork entirely.

Next Steps

From here, a few directions are worth exploring:

  • Test with a real screen reader. This tutorial used Playwright’s accessibility-tree snapshots as a fast, scriptable proxy, which is genuinely useful for catching regressions, but it is not a substitute for listening to your page with NVDA (free, Windows) or VoiceOver (built into macOS and iOS) at least once before you ship something accessibility-critical.
  • Wire axe-core into CI. The run_axe.py script in this tutorial already returns a machine-readable JSON result; failing a build when violations is non-empty is a small step from here and catches regressions automatically on every pull request.
  • Read the WCAG 2.2 Quick Reference. The W3C’s own quickref tool lets you filter all 86 success criteria by level, tag, and technology, and links each one to plain-language “how to meet” guidance with real code examples.

Tags:

AccessibilityAutomated TestingPlaywrightWCAGWeb Development

Share

Con artists run a three-cup shell game on a table in Berlin, luring passersby to guess which cup hides the ball
Previous Post

Docker’s Latest Horror Story Turns a Patched Cursor Bug Into a Sandboxing Argument

A Smiths Detection X-ray baggage screening console with dual monitors showing false-color scans of the contents of a bag
Next Post

Elementor Pro Patches a Critical File Upload Bug That Enabled Remote Code Execution

No Comment! Be the first one.

Leave a Reply Cancel reply

Your email address will not be published. Required fields are marked *

Latest
08 Oct
How to Add Backpressure and Load Shedding to a Python Service Before Overload Takes It Down
08 Oct
GitHub’s Git Rebuild Turns Repository Durability and Read Scale Into Two Separate Problems
Trending
October 8, 2026
How to Add Backpressure and Load Shedding to a Python Service Before Overload Takes It Down
October 8, 2026
GitHub’s Git Rebuild Turns Repository Durability and Read Scale Into Two Separate Problems
October 8, 2026
A Compromised Admin Account Put the Shai-Hulud Worm Into AI Sandbox Maker Tensorlake’s npm SDK
October 8, 2026
How to Prevent Broken Object Level Authorization (IDOR) in a FastAPI App
October 8, 2026
Singapore’s AI Guidelines Turn Independent Review Into a Question of Who Sets the Risk Rating
October 8, 2026
Attackers Hijacked the .gh, .sl and .as Country Domains and Minted HTTPS Certificates for Google

Related Posts

A laptop wrapped in a chain and padlock, illustrating least-privilege controls for AI agents.
Learning Hub

How to Secure Tool-Using AI Agents Before They Touch Production

June 8, 2026
Colorful sticky notes arranged on an office wall, symbolizing governance checklists and planning.
Learning Hub

AI Governance for Agentic Apps: A Practical Checklist for Builders

June 8, 2026
A technician connects green fiber optic cables at a data center, representing a private production inference endpoint.
Learning Hub

How to Deploy a Fine-Tuned LLM Behind a Private Production Inference Endpoint

June 8, 2026
Narrow aisle behind black supercomputer racks in a data center
Learning Hub

Kubernetes SELinux Volume Labeling: What Cluster Operators Should Audit Before v1.37

June 8, 2026
SXZ.io SXZ.io
  • [email protected]

Categories

Articles
Learning Hub
News

All Rights Reserved by SXZ.io ©2026