Web Accessibility: 20 Exercises You Can Check Right in the Browser

от автора

On June 28, 2025, the European Union’s transition period for the European Accessibility Act ended. For a broad range of commercial services—online stores, banks, transport, and telecom—web accessibility stopped being a matter of goodwill.

Technically, the requirement comes down to EN 301 549 and, for the web, WCAG 2.1 Level AA. You cannot learn WCAG by reading it: half the criteria sound perfectly clear while leaving it completely unclear what exactly should be written in the markup.

Some of our projects fall under the new requirements, and it is not only front-end developers who need to relearn their habits. Designers set contrast and focus order, analysts write error messages, and testers need to know what to check manually.

That is how a project of 20 exercises emerged. In each one, the page looks fine at first glance. It is broken only from an accessibility perspective: headings skip levels, a div with a click handler acts as a button, a label sits next to an input but is not formally associated with it. You edit the markup and immediately see the result as the page rebuilds on the fly. Everything runs directly in the browser, and both your code and progress stay in localStorage: a11y-exercises.github.io

What exactly does the EAA require?

The details of the directive are often retold inaccurately, so here is the short version. It is (EU) 2019/882: adopted in April 2019, to be transposed into national law by June 28, 2022, and applicable from June 28, 2025. In other words, 2025 is not the year the law was adopted; it is the year the transition period ended.

  • This is not “all websites,” but a defined range of consumer products and services: e-commerce, banking, transport, telecom, e-books, and terminals.

  • Before it, the private sector in the EU was not covered at all—the earlier (EU) 2016/2102 directive applied only to public-sector bodies. That is the major change.

  • Microenterprises—fewer than 10 people and annual turnover below €2 million—are exempt from obligations concerning services.

  • In Germany, the directive was transposed as the BFSG.

In short, WCAG 2.1 AA has turned from “it would be nice” into a checklist you may be asked to demonstrate compliance with.

An HTML page has a second version, and you cannot see it

From HTML markup, a browser builds not one tree but two. Everyone knows about the DOM. Alongside it, the browser builds the accessibility tree, and it is that tree—not your HTML—that reaches screen readers, braille displays, and voice control.

It has noticeably fewer nodes and is organized differently. Every node has a role, a name, and a state: “button,” “Save draft,” “pressed.” An ordinary <div> containing text may only appear there as an unnamed piece of text, whereas <button>Save</button> becomes a proper node with the button role and the name “Save.” A blind user works with this second tree, so changing markup to make a page accessible matters only insofar as it changes that tree.

The trickiest part is the name. It is not copied from an attribute; it is calculated by a separate algorithm with its own precedence order. Consider this page:

<h2 id="title">Settings</h2>...<button aria-label="Save draft" aria-labelledby="title">Save</button>

Visually, you read “Save.” The code says “Save draft.” But a screen reader will announce “Settings, button,” because aria-labelledby outranks aria-label, which in turn outranks the text inside the button. There are three possible naming sources; one wins, and syntax highlighting will not show you which one.

Everything else follows from that. A check that searches the code for aria-label=»Save draft» will happily accept this button, even though that is not what it is called aloud. That is why the project’s checks do not read source code at all: they wait for the browser to render the page and build both trees, then query the result—an element’s role and name, its computed style, its size, and whether it appears in the second tree at all.

At heart, this is the difference between “the correct method was called in the code” and “the system reached the correct state.” Here, the state is what a real person will hear.

How the project is built

Because there is no server, a reader’s solution stays in their browser, on the same page where the rest of the application runs. This is normally the most uncomfortable part of this kind of training tool: you must run someone else’s program while also allowing for an author freezing a tab with an infinite loop or accessing the training tool’s own data. Hence web workers, separate sandboxes, and watchdog timers.

I was lucky with the subject matter: the answer here is HTML and CSS. It does not need to be executed; it only needs to be rendered. The danger lies elsewhere. Nothing prevents someone from adding <script> to the markup, and if it is inserted into the page as-is, that script runs alongside the application code—with access to saved progress, the other exercises, and everything else.

But this is the important part: none of the twenty exercises need scripts. So there is no need to build a safe JavaScript execution environment. It is enough to make sure that no JavaScript from a solution runs at all. The task is much simpler and is solved with three layers of defense.

First comes sanitization. The source has <script>, <base>, every <meta http-equiv>, and all inline on* handlers removed:

const safeSource = source  .replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, "")  .replace(/<base\b[^>]*>/gi, "")  .replace(/<meta\b[^>]*http-equiv[^>]*>/gi, "")  .replace(/\son[a-z]+\s*=\s*(?:(["'])[\s\S]*?\1|[^\s>]+)/gi, "");

Second comes the sandbox. The markup is placed in an iframe through srcdoc with sandbox=»allow-same-origin». Notice what is absent from that list: allow-scripts. Even if something slips through the regular expression, the browser will not execute it. The console honestly reports Blocked script execution for every such case.

Third comes CSP inside the frame. The preview’s <head> receives its own meta tag:

<meta http-equiv="Content-Security-Policy"      content="default-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src data:">

default-src ‘none’ means no outgoing requests: no images from other domains, no fonts, and no trackers. img-src data: is retained for exercises about alternative text.

The checks themselves run not in the visible preview but in a separate off-screen frame (position: fixed; left: -10000px) with a five-second loading timeout.

The checks then read the rendered document using ordinary browser APIs—getComputedStyle and getBoundingClientRect—and two libraries: isInaccessible from @testing-library/dom (is an element excluded from the accessibility tree?) and computeAccessibleName from dom-accessibility-api (what name does it end up with?). When an exercise is about behavior rather than markup, @testing-library/user-event does the work: it actually clicks and presses keys, and the check observes what happened.

One exercise in detail: four ways to hide something

Let us take exercise ten, which is perhaps the most illustrative. It starts with this scaffold:

<section class="demo">  <h1>Four hiding techniques</h1>  <div class="technique-grid">    <div id="visually-hidden-note" class="hide-everything">Extra context for screen readers</div>    <div id="display-none-note">Closed dialog content</div>    <div id="aria-hidden-note">Decorative confetti</div>    <div id="hidden-note">Inactive step</div>  </div></section>

The task is to hide each of the four blocks, but in different ways:

Technique

Visible

In the accessibility tree

Takes up space

display: none

no

no

no

hidden attribute

no

no

no

aria-hidden=»true»

yes

no

yes

.visually-hidden class

no

yes

almost no (1px)

The first two rows are equivalent in outcome—hidden is essentially display: none from the UA stylesheet—but they differ in the semantics of intent. The last two are mirror opposites, and they are the ones most often confused: aria-hidden hides from the screen reader what remains visible; .visually-hidden hides from sight what a screen reader should read.

Solution:

<div id="visually-hidden-note" class="visually-hidden">Extra context for screen readers</div><div id="display-none-note"    class="display-none">Closed dialog content</div><div id="aria-hidden-note"     aria-hidden="true">Decorative confetti</div><div id="hidden-note"          hidden>Inactive step</div>

Now for the interesting part: how is this checked? No strings, only the state of the rendered document. Here is the check for display: none:

const displayNonePasses =  displayNone !== null &&  displayNoneStyle?.display === "none" &&      // computed style, not an attribute  safelyIsInaccessible(displayNone) &&         // excluded from the accessibility tree  hasZeroBox(box(displayNone)) &&              // no geometry  !displayNone.hasAttribute("hidden") &&       // by this technique specifically,  displayNone.getAttribute("aria-hidden") !== "true"; // not another one

The last two lines matter because without them the task could be solved by “putting every possible attribute on everything.”

aria-hidden is the mirror case:

const ariaHiddenPasses =  ariaHidden.getAttribute("aria-hidden") === "true" &&  safelyIsInaccessible(ariaHidden) &&          // gone from the tree  ariaHiddenStyle.display !== "none" &&        // but remains  ariaHiddenStyle.visibility !== "hidden" &&   // visible  hasPositiveBox(box(ariaHidden));             // with non-zero geometry

So it checks exactly this: “gone from the accessibility tree, but still visible.”

The most tedious check is .visually-hidden, because the canonical recipe specifies not a single property but a combination: the element must remain in the accessibility tree, be position: absolute or fixed, have a non-zero box no larger than 2×2 pixels, and have clipped overflow (clip-path or the legacy clip plus overflow: hidden). The check enumerates all of it literally.

Two tools alongside the checks

The checks answer the question “is the exercise solved?” There are also two additional buttons alongside them.

“What will the screen reader say?”

The first produces a transcript, line by line, of what a screen reader will announce as it moves through the markup from top to bottom.

Under the hood is Guidepup Virtual Screen Reader, a virtual screen reader that runs in the browser on top of the accessibility tree. It attaches to the preview frame’s body, walks the document, and accumulates phrases:

const { virtual } = await import("@guidepup/virtual-screen-reader/browser.js");await virtual.start({ container: document.body, window: frameWindow });for (let step = 0; step < 100; step += 1) {  await virtual.next();  if ((await virtual.lastSpokenPhrase()) === "end of document") break;}const log = (await virtual.spokenPhraseLog()).filter(Boolean);

The 100-step limit protects against markup whose traversal never converges.

You can not only read the transcript but listen to it: the adjacent button sends the log to SpeechSynthesisUtterance. The difference between a button and <div onclick> is invisible to the eye but audible as “Save draft, button” versus simply “Save draft.”

This tool, rather than the checks, most often explains to users why the exercise requires what it requires.

axe-core

The second button runs axe-core, the same engine used by Lighthouse and axe DevTools.

It runs in the third frame: a clone of the preview is taken, scripts, nested frames, object, and embed are removed, on* handlers are stripped, CSP is tightened—and only then is axe injected into this sterile clone. The visible preview is never touched; users should not see anything happen to it.

It does not run every rule, only those relevant to the current exercise:

const result = await frameAxe.run(auditPreview.document.documentElement, {  runOnly: { type: "rule", values: exercise.axeRules },  resultTypes: ["violations", "incomplete"],  iframes: false,  preload: false,});

For icon-only buttons, that is button-name; for headings, heading-order; for hiding techniques, aria-hidden-focus—did you hide from a screen reader something that can still receive focus? A full scan would produce a large number of irrelevant notes about contrast and lang.

axe does not decide whether an exercise passes. By various estimates, automatic checks catch around a third of real accessibility problems: everything that can be formalized as a markup rule. The quality of alternative text, a logical focus order, and the clarity of an error message cannot be caught by anything except a person. A tool that says “0 violations; your product is accessible” does more harm than good, and I tried not to build one.

The cost

“Runs entirely in the browser” sounds attractive until you look at the payload. Here are actual gzipped build figures:

What

Size (gzip)

When it loads

Application with the editor (CodeMirror 6)

204 KB

immediately

React + runtime

~80 KB

immediately

Total first visit

~290 KB

axe-core

150 KB

on the “audit” button

Screen-reader emulator (Guidepup)

~100 KB

on demand

Exercise checks

3–20 KB

one chunk per exercise range

The key decision is to load every heavy component on demand. axe-core weighs more than all the other checking tools combined, so there is no reason to load it for people who never press the audit button. Checks are also split into chunks by exercise range rather than bundled into one file: not everyone reaches exercise 20.

What did not work out

Some topics did not make it into the browser. I left out exercises whose correct answer depends on how NVDA specifically traverses a table or behaves in Forms Mode: it is not possible to test that honestly in a browser.

Some exercises became syntax exercises. Where a WCAG criterion boils down to “add an attribute,” the exercise inevitably checks knowledge of the attribute rather than understanding. I could not always redesign such cases as “choose the right one among several working approaches.”

Source code

The code is open: https://github.com/a11y-exercises/a11y-exercises.github.io, under the MIT license.

The exercises live in app/lib/exercises.ts. Each one is a single structure containing a broken scaffold, a reference solution, task text with a hint, and links to primary sources—WCAG and ARIA APG. The checks live alongside them in app/lib/validators*.ts. To add your own exercise, it is enough to write two functions.

Twenty exercises do not cover the whole subject. Tables, complex widgets such as comboboxes and trees, and mobile gestures are untouched. If you have learned hard lessons with any of those, send them over—I would be happy to add them.

Where it all started: I borrowed the idea from TypeScript Exercises. It uses the same pattern—broken or incomplete code in the browser and a check that looks at the result—applied to the type system. If you like this format, try the original too.

ссылка на оригинал статьи https://habr.com/ru/articles/1065676/