# Accessibility

Semantic HTML, alt text, forms, keyboard, contrast, ARIA and testing, for the sites you build for clients.

Multimedia & Creative Technology · 3.1 Extra Webdev · Self-study · version 0.1.0 · 2026-10-14

Tags: accessibility, html, css, javascript

Slides: <https://slides.mctantwerp.be/en/extra-webdev/07-accessibility>

<!-- slide 2 -->

## After this lesson you can

- Explain who depends on accessibility, and when the European Accessibility Act applies to a client
- Read a WCAG 2.2 success criterion and know which level a site has to reach
- Build pages with headings, landmarks, alt text and forms that a screen reader understands
- Make every part of a site work with a keyboard, with a focus you can see
- Check contrast, zoom and motion, and use ARIA only where HTML falls short
- Test with Lighthouse, axe, the keyboard and a screen reader, and write an accessibility statement

> **Explanation:** The three tasks at the end go through these skills on your own site: an audit, five fixes and a test with a screen reader. If you only have one hour, do the chapters on semantic HTML, forms and keyboard: they cover most of what goes wrong on real sites.

<!-- slide 3 -->

## Agenda

1. Who it is for
2. The law and WCAG
3. Semantic HTML
4. Images and media
5. Forms
6. Keyboard
7. Colour, zoom and motion
8. ARIA and dynamic content
9. Testing and shipping

> **Explanation:** Chapters 1 and 2 are the why and the rules. Chapters 3 to 7 are the building work, one topic each, with a sandbox for most of them. Chapter 8 is for interactive components, and chapter 9 is how you test and what you publish. Already comfortable with semantic HTML? Skim chapter 3, but do the sandbox anyway.

<!-- slide 4 -->

## 1. Who it is for

> **Explanation:** Most developers picture one user when they hear accessibility: someone who is blind, with a screen reader. That is one real group, and far from the only one. This chapter gives you the bigger picture, and the numbers to back it up when a client asks why it costs time.

<!-- slide 5 -->

**1 in 6 people lives with a significant disability.**

The [World Health Organization](https://www.who.int/news-room/fact-sheets/detail/disability-and-health) estimates 1.3 billion people, 16% of the world. That leaves out everybody with a broken wrist, a phone in bright sunlight or a baby on one arm.

> **Explanation:** The WHO counts significant disability, so this leaves out a lot of milder problems, like needing reading glasses. The share grows with age, and Europe is getting older. Many people with a disability do not call themselves disabled and will never tell you: they just leave a site that does not work for them. So 'our customers are not disabled' is never something a client actually knows.

<!-- slide 6 -->

### Permanent, temporary, situational

|   | Permanent | Temporary | Situational |
| --- | --- | --- | --- |
| **Seeing** | Blind, low vision, colour blind | After eye surgery, lost your glasses | Your phone in bright sunlight |
| **Hearing** | Deaf, hard of hearing | An ear infection | A loud train, a sleeping baby next to you |
| **Moving** | One arm, a tremor, paralysis | A broken wrist | A coffee or a child on one arm |
| **Thinking** | Dyslexia, ADHD, a brain injury | A concussion, strong medication | Tired, stressed, reading in your third language |

The split comes from [Microsoft's Inclusive Design](https://inclusive.microsoft.design/) toolkit. What you build for one column, people in the other two use as well.

> **Explanation:** Situational is the column that convinces clients. Captions were made for deaf people, and now half the train watches videos on mute with captions on. A big target to tap helps someone with a tremor and someone on a bumpy bus. When you design for the permanent column, you get the other two for free.

<!-- slide 7 -->

### How people use the web

- **Screen reader**: Reads the page aloud or on a braille display. Jumps by headings, landmarks, links and fields.
- **Keyboard only**: Tab, Enter, Space and the arrows. Because of a motor disability, or by preference.
- **Zoom and magnifier**: Zoom to 200 or 400 percent, or a magnifier that shows a small part of the screen.
- **Voice control**: "Click Add to cart." Works when the visible text is also the name in the code.
- **Switch access**: One or two buttons that step through everything on the page that can get focus.
- **System settings**: Bigger text, less motion, dark mode, captions. Set once, read by your CSS.

More in [How people with disabilities use the web](https://www.w3.org/WAI/people-use-web/) (W3C).

> **Explanation:** Voice control is the one most developers forget. Someone says 'click Add to cart', and the software looks for a control with that name. If your button is a div, or its accessible name differs from the visible text, nothing happens. The W3C page linked here has short stories of real people per type of disability, worth ten minutes.

<!-- slide 8 -->

### Most sites fail

Every year [WebAIM](https://webaim.org/projects/million/) tests the home pages of the top million websites with a tool. In February 2026, 95.9% had failures a tool can detect, 56 per page on average.

| Most common failure | Home pages |
| --- | --- |
| Low contrast text | 83.9% |
| Images without alt text | 53.1% |
| Form fields without a label | 51.0% |
| Empty links, like an icon without text | 46.3% |
| Empty buttons | 30.6% |
| No language on the page | 13.5% |

> **Explanation:** These six are all cheap to fix, and every one of them has its own slide further on. Automated tools only catch part of the problems, so the real number of issues is higher, not lower. Low contrast is number one every year: designers love light grey on white. Look at the full report for the numbers on ARIA, we come back to those in chapter 8.

<!-- slide 9 -->

### Why a client should care

- The law: since June 2025 a webshop that sells to consumers in the EU has to be accessible
- Customers: someone who cannot get through your checkout buys somewhere else
- Search engines and AI agents read the same headings, links and labels as a screen reader
- Fewer support mails: a form with labels and clear errors works better for everyone
- Cheaper: building it in from the start costs less than fixing it after a complaint

> **Explanation:** Put accessibility in your quote as a line from the start (WCAG 2.2 AA), so it is part of the job and not an extra the client has to approve later. Be careful with overlay widgets that promise compliance with one line of JavaScript: they cannot fix your HTML. In 2025 the US Federal Trade Commission ordered accessiBe to pay one million dollars for claiming its widget could make any site WCAG compliant.

<!-- slide 10 -->

## 2. The law and WCAG

> **Explanation:** You will not become a lawyer in this chapter, and you do not have to. What you need: know when the law applies to a client, and know the standard that tells you what to build. That standard is WCAG, and you will see its numbers in every audit report and tool.

<!-- slide 11 -->

### European Accessibility Act

*Directive (EU) [2019/882](https://eur-lex.europa.eu/eli/dir/2019/882/oj), applies since 28 June 2025*

#### Services, online too

- E-commerce for consumers
- Banking
- E-books and the software to read them
- Passenger transport: air, bus, rail and water
- Telephony and access to audiovisual media

#### Products

- Computers and operating systems
- Smartphones and e-readers
- ATMs, ticket and check-in machines
- TV equipment for digital television

> **Explanation:** A directive sets the goal, and every member state writes its own law with the details and the fines, so the rules differ a little per country. For websites and apps, you show that you comply with the European standard EN 301 549, and that standard points to WCAG. Building for a client in another EU country? Check that country's law, the core is the same.

<!-- slide 12 -->

### Does it apply to your client

| Site | EAA |
| --- | --- |
| A webshop for consumers, online banking, buying a train or bus ticket | Yes, small webshops too |
| A webshop of a micro-enterprise: fewer than 10 people, at most €2 million a year | Exempt in the directive. In Belgium: until 28 June 2030 |
| A B2B shop that only sells to companies | No, the EAA is about consumers |
| A portfolio, a brochure site, a menu | No. WCAG is often in the contract anyway |
| A city, a school, a government service | Yes, by an older directive (2016/2102) |

In Belgium the rules are in the Code of Economic Law and the FPS Economy checks them, see [vlaanderen.be](https://www.vlaanderen.be/inter/overheden-en-professionelen/werken-aan-digitale-toegankelijkheid/eu-richtlijn-2019-882-voor-b2c-eaa) (in Dutch).

> **Explanation:** Is a restaurant with an online booking form an e-commerce service? Edge cases like that are for a lawyer, not for you. Build to WCAG 2.2 AA anyway and the question matters a lot less. The micro-enterprise exemption only covers services, and a client who grows past ten people loses it. Service contracts signed before June 2025 may run on unchanged for at most five years.

<!-- slide 13 -->

### WCAG 2.2

The Web Content Accessibility Guidelines of the W3C. Version 2.2 has been the recommendation since October 2023. It added nine success criteria to 2.1, like a minimum size for click targets and focus that a sticky header may not hide.

| Where WCAG shows up | Which version |
| --- | --- |
| EN 301 549, the European standard behind the EAA | WCAG 2.1 AA, and 2.2 AA in version 4.1.1 (2026) |
| Client contracts and tenders | Usually 2.1 AA or 2.2 AA |
| WCAG 3 | A working draft, not a standard yet |

Keep the [WCAG quick reference](https://www.w3.org/WAI/WCAG22/quickref/) open while you build.

> **Explanation:** WCAG 2.2 is backwards compatible: a site that meets 2.2 also meets 2.1 and 2.0. Version 2.2 dropped one old criterion (4.1.1 Parsing), because browsers fixed what it was about. WCAG 3 will score differently and is still being written; do not wait for it. The quick reference is the page to bookmark: filter it on level AA and you have your checklist.

<!-- slide 14 -->

### A, AA and AAA

| Level | What it means | Example |
| --- | --- | --- |
| **A** | The minimum. Without it, some people cannot use the site at all | 1.1.1 Non-text Content: images have a text alternative |
| **AA** | What laws and contracts ask. Includes all of A | 1.4.3 Contrast (Minimum): 4.5:1 for body text |
| **AAA** | The strictest. Not required for a whole site | 1.4.6 Contrast (Enhanced): 7:1 |

A number like 2.4.7 reads as principle 2 (Operable), guideline 2.4 (Navigable), criterion 7 (Focus Visible). Every criterion has an [Understanding page](https://www.w3.org/WAI/WCAG22/Understanding/focus-visible.html) with the why and examples.

> **Explanation:** The W3C itself does not recommend AAA as a policy for whole sites, because some content simply cannot meet it. Single AAA criteria can still be cheap wins, like respecting reduced motion (2.3.3). When an audit report says '2.4.7 fails', open the Understanding page of that number: it explains the intent, gives examples and links to techniques that pass.

<!-- slide 15 -->

### POUR

- **Perceivable**: You can see or hear it: text alternatives, captions, contrast, content that survives zoom.
- **Operable**: You can use it: with a keyboard, with enough time, without flashing, with targets you can hit.
- **Understandable**: You get it: a page language, predictable behaviour, errors that say how to fix them.
- **Robust**: Software can read it: every control has a name, a role and a state, through valid HTML.

> **Explanation:** Robust is the vague one. It means your code has to work with tools you do not know about yet: today's screen readers, voice control, and whatever comes next. In practice that is criterion 4.1.2: every control has a name, a role and a value that software can read. Semantic HTML does that for you, which is the whole next chapter.

<!-- slide 16 -->

### A Belgian webshop with 25 employees sells bike parts to consumers. Since when does the EAA apply to it?

- A. Since 28 June 2025
- B. From 28 June 2030
- C. Never, the EAA is only for government sites
- D. Only once it has more than 250 employees

**Answer:** A. Since 28 June 2025

Selling to consumers online is an e-commerce service, and 25 people is far above the micro-enterprise limit of ten. 2030 is the date for Belgian micro-enterprises and for service contracts signed before June 2025. Government sites fall under a separate directive from 2016.

> **Explanation:** If you got this wrong because you thought of the micro-enterprise rule: it is about fewer than ten people, and both the turnover and the headcount matter. A shop with 25 people is far over it.

<!-- slide 17 -->

## 3. Semantic HTML

> **Explanation:** Most accessibility problems are not exotic. They are a div where a button belongs, a heading picked for its size, a field without a label. Fix the HTML and you solve the majority before you write a single ARIA attribute.

<!-- slide 18 -->

### The accessibility tree

The browser turns your HTML into a second tree for assistive technology. Every node has a role (what it is), a name (what it is called) and a state (checked, expanded). A screen reader reads that tree, not your CSS.

| HTML | Role | Name |
| --- | --- | --- |
| `<button>Add to cart</button>` | button | Add to cart |
| `<a href="/bread">Our bread</a>` | link | Our bread |
| `<input type="checkbox" id="news">` | checkbox, not checked | its label |
| `<div onclick="add()">Add to cart</div>` | none | none, just text |

See it in the [Accessibility pane of Chrome DevTools](https://developer.chrome.com/docs/devtools/accessibility/reference).

> **Explanation:** Open Chrome DevTools, Elements, and look for the Accessibility pane next to Styles. It shows the role, the name and the state of the selected element, and it can switch the Elements panel to the full accessibility tree. Firefox has an Accessibility tab with the same idea. Native elements also bring keyboard behaviour: a button gets focus, Enter and Space for free.

<!-- slide 19 -->

### Headings are the outline

```html
<h1>Bakkerij Peeters</h1>
  <h2>Our bread</h2>
    <h3>Sourdough</h3>
    <h3>Spelt</h3>
  <h2>Opening hours</h2>
  <h2>Contact</h2>
```

Screen reader users jump from heading to heading, often as the first thing they do on a new page. One h1 per page, and no skipped levels.

Pick the level for the structure and the size with CSS. More on [MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/Heading_Elements).

> **Explanation:** Check the outline of any page with the HeadingsMap extension or the Structure tab of WAVE. A common mistake in components: a card title that is an h3 on one page and an h3 under an h1 on another page, skipping h2. Let the component take the level as a prop. The h1 is usually the page title, not the logo.

<!-- slide 20 -->

### Landmarks

```html
<header>
  <a href="/">Bakkerij Peeters</a>
  <nav aria-label="Main">...</nav>
</header>
<main>
  ...
</main>
<footer>
  <nav aria-label="Footer">...</nav>
</footer>
```

A screen reader lists the landmarks and jumps between them: D in NVDA, the rotor in VoiceOver.

One main per page. Two navs? Give each its own name with aria-label, or the list shows two times "navigation".

> **Explanation:** A section element only becomes a landmark (region) when it has a name, through aria-labelledby or aria-label. Same for form. Do not wrap everything in landmarks: five to eight per page is plenty. The landmarks are also why a screen reader user can skip your navigation without a skip link, but keyboard users without a screen reader still need one.

<!-- slide 21 -->

### Button, link or div

|   | Does | Keyboard | Screen reader says |
| --- | --- | --- | --- |
| `<a href>` | Goes to another url, or a place on the page | Enter | "link" |
| `<button>` | Does something here: send, open, add, toggle | Enter and Space | "button" |
| `<div onclick>` | Works with a mouse, and that is it | Nothing, it never gets focus | The text, no role |

Does the url change? A link. Anything else is a [button](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/button). Style them however you like, the element decides what it is.

> **Explanation:** An a without href is no better than a div: no focus, no role. In React, onClick on a div is the most common accessibility bug of all, and eslint-plugin-jsx-a11y flags it. A button inside a form submits that form by default; give it type='button' when it should not. And a link that looks like a button stays a link, which is fine.

<!-- slide 22 -->

### A div is not a button

Run Bad, click an empty part of the page and go on with only Tab and Enter. Then do the same with Good.

```html
<a href="#details">Product details</a>
<div class="btn" onclick="add()">Add to cart</div>
<p id="out"></p>

<style>
  .btn { display: inline-block; margin-top: 24px; padding: 12px 24px;
         background: #1d4ed8; color: white; cursor: pointer; }
</style>
<script>
  let count = 0
  function add() { out.textContent = ++count + ' in your cart' }
</script>
```

*Bad*

```html
<a href="#details">Product details</a>
<div class="btn" onclick="add()">Add to cart</div>
<p id="out"></p>

<style>
  .btn { display: inline-block; margin-top: 24px; padding: 12px 24px;
         background: #1d4ed8; color: white; cursor: pointer; }
</style>
<script>
  let count = 0
  function add() { out.textContent = ++count + ' in your cart' }
</script>
```

*Good*

```html
<a href="#details">Product details</a>
<button class="btn" onclick="add()">Add to cart</button>
<p id="out"></p>

<style>
  .btn { display: block; margin-top: 24px; padding: 12px 24px;
         background: #1d4ed8; color: white; border: 0; }
</style>
<script>
  let count = 0
  function add() { out.textContent = ++count + ' in your cart' }
</script>
```

> **Explanation:** In Bad, Tab goes from the link straight past the div and out of the page. You could fix the div with tabindex='0', role='button' and a keydown handler for Enter and Space, and you would have rebuilt a worse button in six lines. Notice cursor: pointer in Bad: that is how fake buttons hide, they feel right with a mouse.

<!-- slide 23 -->

### Lists and tables

#### Lists

```html
<ul>
  <li><a href="/">Home</a></li>
  <li><a href="/bread">Bread</a></li>
  <li><a href="/contact">Contact</a></li>
</ul>
```

A screen reader says "list, 3 items" first, so you know how long it is.

#### Tables

```html
<table>
  <caption>Opening hours</caption>
  <tr><th scope="col">Day</th><th scope="col">Open</th></tr>
  <tr><th scope="row">Tuesday</th><td>7:00 to 18:00</td></tr>
</table>
```

With th and scope every cell is read with its header. Tables are for data, never for layout.

> **Explanation:** Some CSS resets remove list styling with list-style: none, and Safari then stops announcing the list. Adding role='list' to the ul brings it back, one of the rare cases where ARIA repeats what HTML says. Never use a table for layout: a screen reader announces rows and columns that mean nothing. CSS grid does layout.

<!-- slide 24 -->

### lang and title

```html
<!doctype html>
<html lang="en">
<head>
  <title>Opening hours · Bakkerij Peeters</title>
</head>
<body>
  <p>Try our <span lang="nl">krentenbrood</span>.</p>
```

lang picks the voice and the pronunciation of the screen reader (3.1.1). The title is the first thing a screen reader says and the name of the tab (2.4.2): the page first, then the site.

> **Explanation:** In a single page app, update document.title on every route change, or every page is called the same. A word in another language gets its own lang, like a Dutch product name in an English page: then the screen reader switches voice for that word. Wrong or missing lang makes a French screen reader read your English with a French accent.

<!-- slide 25 -->

## 4. Images and media

> **Explanation:** Images are the second most common failure in the WebAIM Million. The fix is one attribute, but writing a good alt is a skill: you describe what matters in that place on that page. Video and audio come at the end of this chapter.

<!-- slide 26 -->

### Four kinds of images

| Image | alt | Example |
| --- | --- | --- |
| **Informative** | What it shows that matters here | `alt="Four bakers at the oven at five in the morning"` |
| **Decorative** | Empty, never left out | `alt=""` on a wave between two sections |
| **Functional** | Where the link goes, what the button does | `alt="Search"` |
| **Complex** | A short alt, the full information next to it | A chart, a map, a diagram |

Not sure? Follow the [alt decision tree](https://www.w3.org/WAI/tutorials/images/decision-tree/) of the W3C, or read [WebAIM on alt text](https://webaim.org/techniques/alttext/).

> **Explanation:** Do not start with 'image of' or 'photo of': the screen reader already says it is an image. If a caption next to the image says the same thing, use alt='' or a shorter alt, otherwise people hear it twice. A missing alt attribute is worse than an empty one, because many screen readers then read the file name. Text inside an image goes into the alt word for word, or better: make it real text.

<!-- slide 27 -->

### alt in practice

These images do not exist, so the page shows what is left without them. Run Bad and Good and compare what you can still understand.

```html
<a href="/"><img src="logo.png"></a>

<img src="IMG_4032.jpg">

<img src="wave.svg" alt="wave decoration">

<button><img src="search.svg"></button>
```

*Bad*

```html
<a href="/"><img src="logo.png"></a>

<img src="IMG_4032.jpg">

<img src="wave.svg" alt="wave decoration">

<button><img src="search.svg"></button>
```

*Good*

```html
<a href="/"><img src="logo.png" alt="Bakkerij Peeters, home"></a>

<img src="IMG_4032.jpg" alt="Four bakers at the oven at five in the morning">

<img src="wave.svg" alt="">

<button><img src="search.svg" alt="Search"></button>
```

> **Explanation:** This is close to what a screen reader user gets. In Bad the logo link and the search button have no name at all: a screen reader says 'link' and 'button', or reads the file name. 'wave decoration' is noise. In Good the decorative wave disappears completely, which is exactly what you want. Inspect the images in DevTools and compare the names in the Accessibility pane.

<!-- slide 28 -->

### Which alt text

**1. A photo of a sourdough loaf, cut open, on its product page. The name and the price are in the heading next to it.**

- alt="Bread"
- alt="Image of bread on a wooden board"
- **alt="Sourdough cut open, with a dark crust and big holes inside"** ✓

The name is in the heading already, so the alt adds what a sighted visitor gets from the photo: what the bread looks like. "Image of" is said by the screen reader anyway.

**2. A wavy line between two sections, only there for looks.**

- alt="Wave"
- **alt=""** ✓
- No alt attribute

Empty alt tells a screen reader to skip the image. Leave the attribute out and many screen readers read the file name. Even better: a CSS background, then it is not in the HTML at all.

**3. The logo in the header, which links to the home page.**

- alt="logo"
- **alt="Bakkerij Peeters, home"** ✓
- alt=""

The image is all there is in the link, so its alt is the name of the link. "logo" says nothing about where it goes, and an empty alt leaves an empty link.

**4. A magnifier icon in a button, without any text next to it.**

- alt="Magnifying glass"
- **alt="Search"** ✓
- alt="Click to search the website"

A functional image gets what it does, not what it looks like. "Click to" is noise: the screen reader already says it is a button.

**5. A bar chart with the number of orders per month, January to June.**

- alt="Chart"
- **A short alt with the trend, and the numbers in a table under it** ✓
- An alt of twelve sentences with every number

The alt gives the conclusion, the table gives the data, for everybody. A long alt cannot be navigated like a table, and people who see the chart but cannot read small bars get nothing from it.

> **Explanation:** There is rarely one perfect alt. Two people can write different good ones for the same photo. What makes an alt wrong: it says nothing ('image', 'logo'), it repeats what is already next to it, or it describes looks when the image is a link or a button. Context decides: the same photo of a loaf needs another alt on a product page than on the about page.

<!-- slide 29 -->

### Charts and icons

```html
<figure>
  <img src="orders.png" alt="Orders per month, January to June: from 120 to 310">
  <figcaption>Orders more than doubled. <a href="#orders">The numbers per month</a></figcaption>
</figure>

<button aria-label="Close">
  <svg aria-hidden="true" focusable="false">...</svg>
</button>
```

A chart gets a short alt and its data next to it, as text or a table ([complex images](https://www.w3.org/WAI/tutorials/images/complex/)). An icon in a button is hidden, the button gets the name.

> **Explanation:** aria-hidden='true' on the svg keeps the icon out of the tree, so the screen reader only reads the name of the button. An inline SVG that carries information itself gets role='img' and a title or aria-label instead. For a complex diagram, a link to a page with a long description works too. Icon fonts are worse: a screen reader may read the private character as nonsense, so hide those as well.

<!-- slide 30 -->

### Captions and transcripts

| Media | You need | WCAG |
| --- | --- | --- |
| Recorded video with speech | Captions: every word, and the sounds that matter | 1.2.2, A |
| Audio only, like a podcast | A transcript | 1.2.1, A |
| Video where the picture shows what nobody says | Audio description | 1.2.5, AA |
| Sound or motion that starts by itself | A way to stop it: no autoplay with sound, a pause button | 1.4.2 and 2.2.2, A |

```html
<video controls>
  <source src="bakery-tour.mp4" type="video/mp4">
  <track kind="captions" src="bakery-tour.en.vtt" srclang="en" label="English">
</video>
```

> **Explanation:** Auto captions from YouTube are a first draft: names, brands and accents go wrong. Correct them before you publish. Captions are not subtitles: subtitles assume you can hear and only translate the speech, captions also say [door slams] or [music stops]. A transcript also helps search engines, and people who read faster than they listen. Sound that plays for more than three seconds needs a way to stop it, and anything that moves for more than five seconds needs a pause button, like a carousel or a background video.

<!-- slide 31 -->

## 5. Forms

> **Explanation:** Forms are where money is made: a checkout, a booking, a contact form. They are also where accessibility fails most visibly, because a field without a label is a guessing game. Half of all home pages in the WebAIM Million have one.

<!-- slide 32 -->

### Every field gets a label

```html
<label for="email">Email</label>
<input id="email" type="email" autocomplete="email">

<label>
  <input type="checkbox" name="newsletter"> Send me the newsletter
</label>
```

for and id tie the [label](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/label) to the field: the screen reader reads it on focus, and a click on the label puts the cursor in the field. A label around the input works too.

A placeholder is no label. It is gone as soon as you type, and it is light grey by default.

> **Explanation:** When the design really has no room for a visible label, like a search field next to a Search button, use a visually hidden label: a class that hides it on screen but keeps it in the tree. Search 'visually hidden CSS' or use sr-only in Tailwind. Visible is still better: people with memory problems need to see what a field was for, also after they typed.

<!-- slide 33 -->

### Labels

Type an address in both versions, and click the word Email in Good. In Bad, what tells you which field is which once you typed?

```html
<form onsubmit="return false">
  <input type="email" placeholder="Email">
  <input type="password" placeholder="Password">
  <button>Log in</button>
</form>

<style>
  input, button { display: block; margin-bottom: 16px; }
</style>
```

*Bad*

```html
<form onsubmit="return false">
  <input type="email" placeholder="Email">
  <input type="password" placeholder="Password">
  <button>Log in</button>
</form>

<style>
  input, button { display: block; margin-bottom: 16px; }
</style>
```

*Good*

```html
<form onsubmit="return false">
  <label for="email">Email</label>
  <input id="email" type="email" autocomplete="email">
  <label for="password">Password</label>
  <input id="password" type="password" autocomplete="current-password">
  <button>Log in</button>
</form>

<style>
  label, input, button { display: block; }
  input { margin-bottom: 16px; }
</style>
```

> **Explanation:** The bigger click area matters for checkboxes and radio buttons: the box itself is tiny, the label is not. Inspect the fields in DevTools: in Bad, the Accessibility pane may show the placeholder as a fallback name, but that is not something every screen reader or voice control tool does.

<!-- slide 34 -->

### Errors tied to the field

```html
<label for="email">Email</label>
<input id="email" type="email" aria-invalid="true" aria-describedby="email-error">
<p id="email-error">Enter an email address with an @, like lise@example.com</p>
```

Say what is wrong and how to fix it, in text next to the field (3.3.1 and 3.3.3). [aria-describedby](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-describedby) makes the screen reader read the message with the field, aria-invalid says the value is wrong.

A red border alone is not enough: not everybody sees red. More in the W3C [forms tutorial](https://www.w3.org/WAI/tutorials/forms/).

> **Explanation:** With several errors, put a short summary at the top with a link to each field, and move the focus to that summary. Do not validate on every keystroke: a screen reader then shouts 'invalid' while someone is still typing. In Laravel, the @error directive gives you the message per field, so the id and aria-describedby are a few lines in your Blade component.

<!-- slide 35 -->

### Error messages

Send both forms empty and compare what tells you something is wrong, and where the focus is afterwards.

```html
<form novalidate onsubmit="check(event)">
  <input id="email" type="email" placeholder="Email">
  <button>Subscribe</button>
</form>

<style>.wrong { border: 3px solid red; }</style>
<script>
  function check(event) {
    event.preventDefault()
    const email = document.querySelector('#email')
    email.classList.toggle('wrong', !email.value.includes('@'))
  }
</script>
```

*Bad*

```html
<form novalidate onsubmit="check(event)">
  <input id="email" type="email" placeholder="Email">
  <button>Subscribe</button>
</form>

<style>.wrong { border: 3px solid red; }</style>
<script>
  function check(event) {
    event.preventDefault()
    const email = document.querySelector('#email')
    email.classList.toggle('wrong', !email.value.includes('@'))
  }
</script>
```

*Good*

```html
<form novalidate onsubmit="check(event)">
  <label for="email">Email</label>
  <input id="email" type="email" aria-describedby="email-error">
  <p id="email-error" hidden>Enter an email address with an @, like lise@example.com</p>
  <button>Subscribe</button>
</form>

<style>[aria-invalid="true"] { border: 3px solid #b91c1c; } p { color: #b91c1c; }</style>
<script>
  function check(event) {
    event.preventDefault()
    const email = document.querySelector('#email')
    const wrong = !email.value.includes('@')
    email.setAttribute('aria-invalid', wrong)
    document.querySelector('#email-error').hidden = !wrong
    if (wrong) email.focus()
  }
</script>
```

> **Explanation:** novalidate switches off the browser's own error bubbles so you can show your own. The browser bubbles are accessible, by the way, they just cannot be styled. In Good the focus moves back to the field, so a screen reader reads the label, the error and 'invalid entry' in one go. In Bad a screen reader user hears nothing at all after pressing the button.

<!-- slide 36 -->

### required, autocomplete and fieldset

```html
<fieldset>
  <legend>Delivery</legend>
  <label>
    <input type="radio" name="delivery" required> Pick up
  </label>
  <label>
    <input type="radio" name="delivery"> Delivery
  </label>
</fieldset>

<label for="name">Name (required)</label>
<input id="name" autocomplete="name" required>
```

required is read out and checked by the browser. A red asterisk on its own is not.

[autocomplete](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Attributes/autocomplete) fills in name, email and address (1.3.5). A big help for anyone who types slowly.

fieldset and legend give a group of radios its question.

> **Explanation:** Two newer criteria in WCAG 2.2 are about forms too. 3.3.7 Redundant Entry: do not ask for the same information twice in one process, like a delivery address that is also the billing address. 3.3.8 Accessible Authentication: never block paste in a password or code field, because password managers paste. The autocomplete values are listed on MDN.

<!-- slide 37 -->

### A login form uses only placeholders, no labels, because it looks cleaner. What is the problem?

- A. The hint is gone once you type, and it is no reliable name for the field
- B. Nothing, a screen reader reads a placeholder just like a label
- C. Placeholders are not valid HTML
- D. Only that the text is a bit small

**Answer:** A. The hint is gone once you type, and it is no reliable name for the field

Some screen readers fall back on the placeholder when there is no label, but not all of them, and voice control may not find the field. And once you type, the hint is gone, so you have to remember which field was which. The default placeholder grey also fails contrast.

> **Explanation:** A placeholder is still useful for an example of the format, like 'lise@example.com' or 'BE68 5390 0754 7034'. Just not as the only name of the field.

<!-- slide 38 -->

## 6. Keyboard

> **Explanation:** The keyboard test is the cheapest test there is: no tool, no extension, five minutes. Everyone who uses a screen reader, a switch or voice control depends on what works with a keyboard, so this chapter covers far more people than keyboard users alone.

<!-- slide 39 -->

### Tab through it

| Key | Does |
| --- | --- |
| <kbd>Tab</kbd> and <kbd>Shift</kbd> + <kbd>Tab</kbd> | Next or previous link, button or field |
| <kbd>Enter</kbd> | Follows a link, presses a button |
| <kbd>Space</kbd> | Presses a button, ticks a checkbox, scrolls the page |
| <kbd>↑ ↓ ← →</kbd> | Choose within radios, a select, tabs or a menu |
| <kbd>Esc</kbd> | Closes a modal or a menu |

The focus follows the order of your HTML (2.4.3). Do not fix the order with tabindex="3", change the HTML. tabindex="0" makes something focusable, tabindex="-1" only from a script.

> **Explanation:** On a Mac, Safari skips links when you Tab, until you switch on 'Press Tab to highlight each item on a webpage' in Safari's settings, under Advanced. Test with Chrome or Firefox first if you are on a Mac. tabindex with a positive number (1, 2, 3) breaks the natural order for the whole page; nearly every audit flags it.

<!-- slide 40 -->

### Never hide the focus

```css
/* Bad: gone for everyone */
*:focus {
  outline: none;
}

/* Good: visible when it helps */
:focus-visible {
  outline: 3px solid #1d4ed8;
  outline-offset: 3px;
}
```

[:focus-visible](https://developer.mozilla.org/en-US/docs/Web/CSS/:focus-visible) matches when the browser decides a focus ring helps: when you Tab, not when you click a button with the mouse.

So the old reason for outline: none ("ugly ring on click") is gone. Focus Visible is 2.4.7, level AA.

> **Explanation:** WCAG 2.2 also has 2.4.13 Focus Appearance, at level AAA: a focus indicator of a minimum size and contrast. A solid outline of 2 or 3 pixels in a colour with 3:1 against the background gets you there. Many CSS resets and old Bootstrap themes contain outline: none, so search your CSS for it. In Tailwind the focus-visible: variant does the same as the selector here.

<!-- slide 41 -->

### Focus visible

Click an empty part of the page and Tab through the links, first in Bad, then in Good. Where are you after three Tabs?

```html
<nav>
  <a href="#">Home</a>
  <a href="#">Bread</a>
  <a href="#">Pastry</a>
  <a href="#">Contact</a>
</nav>

<style>
  a { color: #111; margin-right: 24px; text-decoration: none; }
  a:focus { outline: none; }
</style>
```

*Bad*

```html
<nav>
  <a href="#">Home</a>
  <a href="#">Bread</a>
  <a href="#">Pastry</a>
  <a href="#">Contact</a>
</nav>

<style>
  a { color: #111; margin-right: 24px; text-decoration: none; }
  a:focus { outline: none; }
</style>
```

*Good*

```html
<nav>
  <a href="#">Home</a>
  <a href="#">Bread</a>
  <a href="#">Pastry</a>
  <a href="#">Contact</a>
</nav>

<style>
  a { color: #111; margin-right: 24px; text-decoration: none; }
  a:focus-visible { outline: 4px solid #1d4ed8; outline-offset: 6px; }
</style>
```

> **Explanation:** Imagine that page with forty links instead of four, and no visible focus: you press Tab, something happens somewhere, and you have no idea where. The outline-offset in Good keeps the ring off the text, which looks cleaner and is easier to see. Pick a focus colour with at least 3:1 contrast against your background.

<!-- slide 42 -->

### Skip link

```html
<body>
  <a class="skip-link" href="#main">Skip to content</a>
  <header>... forty links ...</header>
  <main id="main" tabindex="-1">
```

```css
.skip-link { position: absolute; top: -100px; left: 1rem; }
.skip-link:focus { top: 1rem; }
```

The first Tab on the page shows it, Enter jumps past the navigation (2.4.1). Without it, a keyboard user tabs through your whole menu on every page. More at [WebAIM](https://webaim.org/techniques/skipnav/).

> **Explanation:** Do not hide it with display: none, then it cannot get focus at all. tabindex='-1' on main makes sure the focus really lands there in every browser, so the next Tab continues in the content. Look at gov.uk: press Tab once on any page and you see a good one. A site with very little navigation needs it less, but it never hurts.

<!-- slide 43 -->

### Traps and hidden focus

- No keyboard trap (2.1.2): you can always Tab or Esc out again. Embedded maps, players and chat widgets are the usual suspects.
- A closed off-canvas menu has no focusable links: use hidden, display: none or inert, not only a transform.
- A sticky header may not cover the element with focus (2.4.11). scroll-padding-top as high as the header fixes most of it.
- Targets are at least 24 by 24 CSS pixels, or have enough space around them (2.5.8).

> **Explanation:** A modal keeps the focus inside while it is open, and that is not a trap, because Esc or a close button gets you out. A trap is when nothing gets you out. For the sticky header, html { scroll-padding-top: 5rem } with the height of your header is usually enough. Target size has exceptions, like a link inside a sentence.

<!-- slide 44 -->

### Your off-canvas menu is moved out of view with transform: translateX(100%) when it is closed. What happens when a keyboard user tabs through the page?

- A. Nothing, the links are out of view
- B. The focus disappears into eight invisible links
- C. The browser skips elements outside the viewport
- D. The menu opens by itself

**Answer:** B. The focus disappears into eight invisible links

A transform only moves the pixels. The links are still in the page and in the tab order, so the focus goes off screen for a while and nobody can see where. Add inert or hidden while the menu is closed, or visibility: hidden, which also takes the links out of the tab order.

> **Explanation:** inert is the cleanest fix: it takes a whole subtree out of the tab order and the accessibility tree at once. Add it when the menu closes, remove it when it opens. Check it in the Accessibility pane: an inert element is marked there.

<!-- slide 45 -->

## 7. Colour, zoom and motion

> **Explanation:** This chapter is the part you share with the designer. Contrast and focus states are decided in Figma, long before you write CSS. Bring the numbers on the next slides to the design review, not to the bug report.

<!-- slide 46 -->

### Contrast ratios

| What | AA | AAA |
| --- | --- | --- |
| Body text | 4.5:1 | 7:1 |
| Large text: from 24px, or 18.66px bold | 3:1 | 4.5:1 |
| Icons, input borders, focus rings, chart lines (1.4.11) | 3:1 | not defined |

1.4.3 is about text, 1.4.11 about everything else you need to see to use the page. Check any pair of colours with the [WebAIM contrast checker](https://webaim.org/resources/contrastchecker/), or read the [Understanding page](https://www.w3.org/WAI/WCAG22/Understanding/contrast-minimum.html).

> **Explanation:** 18pt is 24px, and 14pt bold is about 18.66px: that is where 'large text' starts. Logos and disabled controls are exempt. For text on a photo, measure against the lightest part behind the text, or put a dark overlay under it. The colour picker in Chrome DevTools shows the ratio of any text colour, with lines for AA and AAA.

<!-- slide 47 -->

### Contrast in practice

Opening hours

#aaaaaa on #ffffff: 2.32:1, **Fails**

Opening hours

#9ca3af on #ffffff: 2.54:1, **Fails**

Opening hours

#767676 on #ffffff: 4.54:1, **AA**

Opening hours

#595959 on #ffffff: 7.00:1, **AAA**

Opening hours

#ffffff on #3b82f6: 3.68:1, **Large text only**

Opening hours

#ffffff on #2563eb: 5.17:1, **AA**

#9ca3af and #3b82f6 are gray-400 and blue-500 of Tailwind 3: popular, and too light for body text.

> **Explanation:** Light grey for secondary text and white on a medium blue for buttons are the two classics. Both look fine on a designer's calibrated screen and disappear on a cheap laptop in the sun. The ratios here are calculated with the WCAG formula; check your own colours, a shade darker usually solves it without anyone noticing a difference in the design.

<!-- slide 48 -->

### Not only colour

| Only colour | Better |
| --- | --- |
| A link in a sentence, only blue | Blue and underlined |
| A field with a red border | A red border, an icon and a message |
| A chart with a green and a red line | Labels on the lines, or different line styles |
| Available sizes in green, sold out in grey | Sold out crossed out, or with the word |

About 1 in 12 men and 1 in 200 women see colours differently ([IET](https://www.theiet.org/impact-society/factfiles/engineering-safety-factfiles/colour-vision-in-the-workplace/)). Colour may help, it may not be the only clue ([1.4.1](https://www.w3.org/WAI/WCAG22/Understanding/use-of-color.html), level A).

> **Explanation:** Chrome DevTools can show your page the way people with a colour vision deficiency see it: open the Command Menu, type 'Rendering', and pick a type under Emulate vision deficiencies. Red and green are the pair that goes wrong most often, so 'red is wrong, green is right' needs an icon or a word next to it.

<!-- slide 49 -->

### Zoom and reflow

1. **200% text**: Text can grow to twice its size without content getting lost or cut off (1.4.4)
2. **400% zoom**: Nothing scrolls sideways at 400%, like a screen of 320px wide ([1.4.10](https://www.w3.org/WAI/WCAG22/Understanding/reflow.html))
3. **rem, not px**: Font sizes in rem follow the font size someone set in their browser, px ignores it
4. **No fixed heights**: A box with text and a fixed height overflows as soon as the text grows

Zoom in with Windows <kbd>Ctrl</kbd>+<kbd>+</kbd>, Mac <kbd>Cmd</kbd>+<kbd>+</kbd>, out with Windows <kbd>Ctrl</kbd>+<kbd>-</kbd>, Mac <kbd>Cmd</kbd>+<kbd>-</kbd>.

> **Explanation:** A responsive layout gets you most of the way to reflow: zooming to 400% on a 1280px screen gives the same width as a 320px phone. Content that needs two dimensions, like a data table, a map or a code block, may scroll sideways. Never put maximum-scale=1 or user-scalable=no in your viewport meta tag: it blocks pinch zoom on phones for everyone.

<!-- slide 50 -->

### User preferences in CSS

```css
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 0.01ms !important;
    transition-duration: 0.01ms !important;
    scroll-behavior: auto !important;
  }
}

@media (prefers-color-scheme: dark) {
  :root { --bg: #111; --text: #eee; }
}
```

People with a vestibular disorder can get dizzy or sick from parallax and big slide-ins. They switch on Reduce motion in their system, [your CSS reads it](https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-reduced-motion).

[Dark mode](https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-color-scheme) is a preference too. Check your contrast in both themes.

> **Explanation:** Reduce does not mean none: a short fade is usually fine, a parallax background or a page that zooms in is not. A cleaner way is to only add animations inside @media (prefers-reduced-motion: no-preference). Also look at prefers-contrast and forced-colors: Windows users with a contrast theme get your page in their own colours, so test that a button without a border still looks like a button.

<!-- slide 51 -->

### Your designer picks #999999 for body text on white. The contrast checker says 2.85:1. Is that fine for WCAG AA?

- A. No, body text needs at least 4.5:1
- B. Yes, 3:1 is enough for all text
- C. Yes, as long as the font is 16px or bigger
- D. Yes, only AAA cares about grey text

**Answer:** A. No, body text needs at least 4.5:1

3:1 is the minimum for large text (24px, or 18.66px bold) and for icons and borders. Body text at 16px is normal size and needs 4.5:1. And 2.85:1 is below 3:1 anyway, so it would fail for large text too.

> **Explanation:** The nearest grey that passes on white is #767676 at 4.54:1. Most people cannot tell #999 and #767676 apart in a design at a glance, but the difference in readability is big on a bad screen.

<!-- slide 52 -->

## 8. ARIA and dynamic content

> **Explanation:** ARIA is the toolbox for what HTML cannot express on its own: a tab panel, a menu that opens, a message that appears. It is also where most new accessibility bugs come from, so this chapter starts with when not to use it.

<!-- slide 53 -->

> If you can use a native HTML element or attribute with the semantics and behavior you require already built in, instead of re-purposing an element and adding an ARIA role, state or property to make it accessible, then do so.
>
> W3C, *[Using ARIA, the first rule of ARIA use](https://www.w3.org/TR/using-aria/)*

> **Explanation:** ARIA stands for Accessible Rich Internet Applications. It only changes what the accessibility tree says, it adds no behaviour: role='button' on a div does not make it focusable and does not react to Enter. That is the whole catch. Use it to fill gaps HTML leaves, like aria-expanded on a button that opens a panel.

<!-- slide 54 -->

### ARIA can make it worse

In the WebAIM Million 2026, home pages with ARIA had 59.1 errors on average, pages without 42. That does not prove ARIA causes them, those pages are also more complex. It does show ARIA is no fix in itself.

```html
<!-- A role without the behaviour: no focus, no Enter -->
<div role="button">Order</div>

<!-- The name replaces the visible text: voice control fails -->
<button aria-label="Submit form">Order now</button>

<!-- Hidden from screen readers, still focusable -->
<a href="/cart" aria-hidden="true">Cart</a>
```

> **Explanation:** Criterion 2.5.3 Label in Name says the accessible name has to contain the visible text. A voice control user says 'click Order now', and the software looks for exactly that name. aria-hidden on something that can get focus is worse than useless: the keyboard lands on it and the screen reader says nothing.

<!-- slide 55 -->

### Attributes worth knowing

| Attribute | Use it for |
| --- | --- |
| `aria-label` | A name for a control without visible text, like an icon button |
| `aria-labelledby, aria-describedby` | Point at text already on the page: a heading as name, an error as description |
| `aria-expanded` | A button that opens and closes something: true or false |
| `aria-current="page"` | The link to the page you are on, in the navigation |
| `role="status", aria-live` | A message that shows up without a page load |
| `aria-hidden="true"` | Decoration a screen reader may skip, like an icon next to text |

Building a tab panel or a menu? The [ARIA Authoring Practices Guide](https://www.w3.org/WAI/ARIA/apg/) has the keys and attributes per pattern.

> **Explanation:** Building a tab panel, a combobox or a menu yourself? Copy the keyboard behaviour from the ARIA Authoring Practices Guide: per pattern it says which keys do what and which attributes change. Better still, use a component library that did that work and test it with a keyboard anyway. aria-labelledby and aria-describedby take one or more ids, separated by spaces.

<!-- slide 56 -->

### aria-expanded

Open the opening hours with the keyboard in both versions. Then inspect the button of Good in DevTools and watch expanded change in the Accessibility pane.

```html
<div class="toggle" onclick="toggle()">Opening hours ▾</div>
<p id="hours" hidden>Tuesday to Saturday, 7:00 to 18:00</p>

<style>.toggle { cursor: pointer; font-weight: bold; }</style>
<script>
  function toggle() {
    const hours = document.querySelector('#hours')
    hours.hidden = !hours.hidden
  }
</script>
```

*Bad*

```html
<div class="toggle" onclick="toggle()">Opening hours ▾</div>
<p id="hours" hidden>Tuesday to Saturday, 7:00 to 18:00</p>

<style>.toggle { cursor: pointer; font-weight: bold; }</style>
<script>
  function toggle() {
    const hours = document.querySelector('#hours')
    hours.hidden = !hours.hidden
  }
</script>
```

*Good*

```html
<button aria-expanded="false" aria-controls="hours" onclick="toggle(this)">
  Opening hours
</button>
<p id="hours" hidden>Tuesday to Saturday, 7:00 to 18:00</p>

<script>
  function toggle(button) {
    const open = button.getAttribute('aria-expanded') === 'true'
    button.setAttribute('aria-expanded', !open)
    document.querySelector('#hours').hidden = open
  }
</script>
```

> **Explanation:** This is the disclosure pattern from the APG, the simplest one there is. In Good, a screen reader says 'Opening hours, button, collapsed', and 'expanded' after Enter. In Bad it says 'Opening hours' and nothing else, and a keyboard cannot reach it. The details and summary elements in HTML do the same without any JavaScript, if you do not need custom animation.

<!-- slide 57 -->

### Modals with dialog

```html
<button id="open">Book a table</button>

<dialog id="booking" aria-labelledby="booking-title">
  <h2 id="booking-title">Book a table</h2>
  ...
  <form method="dialog">
    <button>Close</button>
  </form>
</dialog>

<script>
  const booking = document.querySelector('#booking')
  document.querySelector('#open')
    .addEventListener('click', () => booking.showModal())
</script>
```

- showModal() puts the focus in the dialog
- The rest of the page is inert: no clicks, no focus
- Esc closes it
- On close, the focus goes back to the button
- More on [MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/dialog)

> **Explanation:** show() opens a dialog without making the rest inert; for a modal you want showModal(). The closedby attribute and the ::backdrop pseudo-element let you style and fine-tune it. If your framework or component library has its own modal, check that it does these four things, many older ones do not return the focus.

<!-- slide 58 -->

### A modal that behaves

Open the booking with Tab and Enter in both versions, press Tab a few times, then Esc. Where is the focus?

```html
<button onclick="show(true)">Book a table</button>
<a href="#">Menu</a>

<div id="booking" class="overlay">
  <p>A table for two at 19:00?</p>
  <button onclick="show(false)">Confirm</button>
</div>

<style>
  .overlay { display: none; position: fixed; inset: 0;
             background: #000d; color: white; padding: 40px; }
</style>
<script>
  function show(open) {
    document.querySelector('#booking').style.display = open ? 'block' : 'none'
  }
</script>
```

*Bad*

```html
<button onclick="show(true)">Book a table</button>
<a href="#">Menu</a>

<div id="booking" class="overlay">
  <p>A table for two at 19:00?</p>
  <button onclick="show(false)">Confirm</button>
</div>

<style>
  .overlay { display: none; position: fixed; inset: 0;
             background: #000d; color: white; padding: 40px; }
</style>
<script>
  function show(open) {
    document.querySelector('#booking').style.display = open ? 'block' : 'none'
  }
</script>
```

*Good*

```html
<button onclick="booking.showModal()">Book a table</button>
<a href="#">Menu</a>

<dialog id="booking" aria-labelledby="booking-title">
  <h2 id="booking-title">Book a table</h2>
  <p>A table for two at 19:00?</p>
  <form method="dialog"><button>Confirm</button></form>
</dialog>
```

> **Explanation:** Everything Good does, Bad would need about forty lines of JavaScript for: catching Tab at the edges, Esc, making the background inert, remembering and restoring the focus. Most home-made modals forget at least one. form method='dialog' closes the dialog when its button is pressed, without any script.

<!-- slide 59 -->

### Focus after a change

In a single page app nothing tells a screen reader the page changed. After a route change, move the focus to the new h1 and update the title. After deleting a row, move it to something that still exists.

```js
// After a route change
document.title = 'Our bread · Bakkerij Peeters'
const heading = document.querySelector('h1')
heading.setAttribute('tabindex', '-1')
heading.focus()
```

> **Explanation:** Some frameworks do part of this for you: Next.js has a route announcer that reads the new title. Check what yours does with a screen reader before you add your own. The same thinking goes for deleting: when a row disappears and it had the focus, the focus falls back to the body, and a keyboard user starts at the top again.

<!-- slide 60 -->

### Live regions

```html
<div role="status" id="cart-status"></div>

<script>
  // Later, after an item was added
  document.querySelector('#cart-status').textContent = 'Sourdough added. 3 items in your cart.'
</script>
```

- role="status" is read when the screen reader is done talking (4.1.3)
- role="alert" interrupts: only for errors that cannot wait
- The region is in the page before the text changes, or nothing is read. More on [MDN](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Guides/Live_regions)

> **Explanation:** The empty region has to be in the page when it loads. A region you add together with its text is often not read, because the screen reader did not watch it yet. Do not make everything live: a chat that announces every message, or a counter that ticks, makes a page unusable. One status region per page, reused for every message, is a good default.

<!-- slide 61 -->

## 9. Testing and shipping

> **Explanation:** Testing accessibility is a mix of tools and your own hands. Tools are fast and catch the obvious, your keyboard and a screen reader catch the rest. At the end of this chapter you also write down what you found, in an accessibility statement.

<!-- slide 62 -->

### Automated tools

- **[Lighthouse](https://developer.chrome.com/docs/lighthouse/accessibility/scoring)**: Built into Chrome DevTools. A score and a list, based on the axe rules.
- **[axe DevTools](https://www.deque.com/axe/devtools/)**: A free browser extension by Deque, with more detail and a fix per issue.
- **[WAVE](https://wave.webaim.org/)**: WebAIM's extension: icons on your page where the problems are, and the heading structure.

Deque found that automated tests catch about [57% of issues](https://www.deque.com/blog/automated-testing-study-identifies-57-percent-of-digital-accessibility-issues/) in their audits. No tool can tell whether an alt text is right, the tab order makes sense or an error message helps.

> **Explanation:** Deque's 57% counts issues by volume in their own audits, and they make axe, so take it as an upper estimate. Other studies put it lower. Either way: a perfect score means the tool found nothing, not that the site works. axe-core also runs in your tests: @axe-core/playwright scans a page in a Playwright test, so a missing label fails your CI.

<!-- slide 63 -->

### The keyboard test

1. **Mouse away**: Click in the address bar once, then only Tab, Shift+Tab, Enter, Space, the arrows and Esc
2. **See**: Can you see where the focus is on every Tab, and does it move in reading order?
3. **Reach**: Can you reach and use everything: menu, buttons, form, modal, cookie banner?
4. **Leave**: Can you get out of everything again?
5. **Skip**: Does the first Tab show a skip link?

> **Explanation:** Do this on the pages that matter most: home, one content page, and every form, checkout included. Cookie banners are a classic failure: they show up first, cover the page, and often cannot be reached or closed with a keyboard. Write down every place where you got stuck, with the page and the element: that is your list for task 2.

<!-- slide 64 -->

### Screen reader keys

[VoiceOver](https://support.apple.com/guide/voiceover/welcome/mac) is built into every Mac, [NVDA](https://www.nvaccess.org/) is free for Windows. Windows keys below are NVDA, Mac keys are VoiceOver.

| Do | Keys |
| --- | --- |
| Start | Windows <kbd>Ctrl</kbd>+<kbd>Alt</kbd>+<kbd>N</kbd>, Mac <kbd>Cmd</kbd>+<kbd>F5</kbd> |
| Stop talking | Windows <kbd>Ctrl</kbd>, Mac <kbd>Ctrl</kbd> |
| Next heading | Windows <kbd>H</kbd>, Mac <kbd>Ctrl</kbd>+<kbd>Option</kbd>+<kbd>Cmd</kbd>+<kbd>H</kbd> |
| Next item | Windows <kbd>↓</kbd>, Mac <kbd>Ctrl</kbd>+<kbd>Option</kbd>+<kbd>→</kbd> |
| Press a link or a button | Windows <kbd>Enter</kbd>, Mac <kbd>Ctrl</kbd>+<kbd>Option</kbd>+<kbd>Space</kbd> |
| List of headings, links and landmarks | Windows <kbd>Insert</kbd>+<kbd>F7</kbd>, Mac <kbd>Ctrl</kbd>+<kbd>Option</kbd>+<kbd>U</kbd> |

> **Explanation:** NVDA uses Insert as its own modifier key (Caps Lock if you choose so); the H, D and K keys work in browse mode, which is the default on a web page. NVDA+Space switches to focus mode for typing in fields. Use VoiceOver with Safari and NVDA with Chrome or Firefox, those are the pairs people use. Start slow: the first ten minutes are confusing for everyone.

<!-- slide 65 -->

### Lighthouse gives your site 100 for accessibility. What do you know now?

- A. The automated checks found nothing, the rest you still have to test yourself
- B. The site meets WCAG 2.2 AA
- C. The site works with every screen reader
- D. You can state in your accessibility statement that it fully complies

**Answer:** A. The automated checks found nothing, the rest you still have to test yourself

Lighthouse runs a subset of the axe rules and only scores what a script can check. A page full of alt="image", a focus order that jumps around or a modal you cannot leave can still score 100. WCAG compliance needs the keyboard test, a screen reader and a human looking at the content.

> **Explanation:** Treat a score as a smoke alarm: when it goes off, something is wrong for sure. When it stays quiet, the house can still be on fire in a room it does not reach. Use the score in a pull request to catch regressions, not as proof for a client.

<!-- slide 66 -->

### An accessibility statement

A page that says how accessible the site is, and what to do when something does not work.

- The standard and the level you aim for: WCAG 2.2 AA
- What does not work yet, and an alternative in the meantime
- A contact for problems, with how fast you answer
- When and how the site was last checked

The W3C has a [statement generator](https://www.w3.org/WAI/planning/statements/generator/) that asks the right questions.

> **Explanation:** Public sector sites in the EU must have a statement since the Web Accessibility Directive. The EAA asks service providers to explain how their service meets the requirements, and a statement is the usual way to do that. Be honest: a statement with known issues and a working contact is worth more than 'fully compliant' that is not true. Put it in the footer, next to the privacy policy.

<!-- slide 67 -->

### Task: Audit your own site

Take a site you built: your portfolio, a school project or a client site. Stuck? Every chapter links to the docs, and all links are behind the book icon.

1. Run Lighthouse and axe DevTools on the home page and on a page with a form, and save both reports
2. Do the keyboard test on both pages (The keyboard test)
3. Check the heading outline with WAVE, and three text colours with the WebAIM contrast checker
4. List every issue: what, where, which WCAG criterion, and how much it blocks someone
5. Done when your list has at least ten issues, each with a criterion number

> **Explanation:** Pick a site with at least one form; a page without interaction hides half the problems. If the site is not online, run it locally: Lighthouse, axe and WAVE all work on 127.0.0.1. Write the criterion number next to each issue, the quick reference helps you find it. Ten issues sounds like a lot, it rarely is.

<!-- slide 68 -->

### Task: Fix five issues

On the list from your audit. Stuck on a fix? Open the Understanding page of the criterion, it lists techniques that pass.

1. Pick the five issues that block people most, not the five easiest ones
2. Fix them in the HTML and CSS first, ARIA only where HTML cannot (first rule of ARIA)
3. One commit per fix, with the criterion in the message: "Add labels to the contact form (3.3.2)"
4. Run the same tests again and check that the issue is gone and nothing new showed up
5. Done when five fixes are pushed and your list says what is fixed and what is left

> **Explanation:** Start with what blocks people: a form you cannot send with a keyboard beats a contrast issue in the footer. If a fix needs ARIA, read the slide on the first rule again and check whether an HTML element does it. After every fix, run the same test that found the issue, not only Lighthouse.

<!-- slide 69 -->

### Task: Listen to your site

With VoiceOver on a Mac or NVDA on Windows (Screen reader keys). Stuck? The VoiceOver guide and the NVDA key commands are in the links.

1. Go through your home page by headings, then by landmarks. Do you know what the page is about?
2. Fill in your form and make a mistake on purpose. Do you hear what went wrong?
3. Write an accessibility statement: standard, known issues, contact and date
4. Done when the statement is on your site and linked from the footer

> **Explanation:** The first time with a screen reader is slow and confusing, that is normal. Give yourself twenty minutes. Turn the screen off or close your eyes for part of it, otherwise you keep cheating with your eyes. If you only hear 'link, link, link, button', that is exactly the kind of issue to write down.

## Glossary

- **Accessibility**: Building a site so people with a disability can perceive, understand and use it, with whatever they use: a screen reader, a keyboard, zoom or voice. Often written as a11y. (slide 5)
- **Situational disability**: A limit that comes from where you are, not from your body: bright sun on your phone, a loud train, a child on one arm. What works for a permanent disability helps here too. (slide 6)
- **European Accessibility Act**: EU directive 2019/882, applied since 28 June 2025. It covers a list of products and services, among them e-commerce for consumers, banking, e-books and passenger transport. (slide 11)
- **Micro-enterprise**: A company with fewer than 10 people and at most 2 million euro turnover or balance sheet a year. The EAA exempts them for services; Belgium gives them until 28 June 2030. (slide 12)
- **WCAG**: Web Content Accessibility Guidelines of the W3C. Version 2.2 is the current one: success criteria per guideline, each at level A, AA or AAA. (slide 13)
- **Conformance level**: A is the minimum, AA is what laws and contracts ask (and includes A), AAA is the strictest and not meant for a whole site. (slide 14)
- **POUR**: The four principles of WCAG: Perceivable, Operable, Understandable and Robust. Every success criterion falls under one of them. (slide 15)
- **Accessibility tree**: The tree the browser builds from your HTML for assistive technology, with a role, a name and a state per element. A screen reader reads this tree, not your CSS. (slide 18)
- **Landmark**: A region of the page with a fixed role, like header, nav, main and footer. Screen reader users list them and jump between them. (slide 20)
- **Alt text**: The text alternative of an image in the alt attribute: what it shows or does, in context. A decorative image gets alt="", so a screen reader skips it. (slide 26)
- **Captions**: Text of everything that is said in a video, plus the sounds that matter. Unlike subtitles, they assume you cannot hear the audio. Required at level A for recorded video. (slide 30)
- **Focus indicator**: The visible outline on the element that has keyboard focus. :focus-visible shows it when it helps, so outline: none is never needed. (slide 40)
- **Skip link**: The first link on a page, visible on focus, that jumps past the navigation to the main content. (slide 42)
- **Keyboard trap**: A part of the page you can tab into but not out of again. Embedded players, maps and chat widgets are the usual suspects. (slide 43)
- **Contrast ratio**: How far two colours are apart in brightness, from 1:1 to 21:1. Level AA asks 4.5:1 for body text and 3:1 for large text, icons and borders. (slide 46)
- **Reflow**: At 400% zoom (like a 320px wide screen) the content fits the width without scrolling sideways. WCAG 2.2 criterion 1.4.10, level AA. (slide 49)
- **prefers-reduced-motion**: A media query that tells you the visitor switched on Reduce motion in their system. Tone down parallax, zooms and big transitions for them. (slide 50)
- **ARIA**: Attributes (role, aria-\*) that change what the accessibility tree says about an element. They add no behaviour, and the first rule is to use HTML instead when HTML can do it. (slide 53)
- **Live region**: An element with role="status", role="alert" or aria-live whose new text a screen reader reads without the focus moving there. For messages like "added to your cart". (slide 60)

## Links and sources

### In the deck

- [Disability and health](https://www.who.int/news-room/fact-sheets/detail/disability-and-health) (slide 5): WHO fact sheet: 1.3 billion people, 16%, 1 in 6
- [Microsoft Inclusive Design](https://inclusive.microsoft.design/) (slide 6): Permanent, temporary and situational
- [How people with disabilities use the web](https://www.w3.org/WAI/people-use-web/) (slide 7)
- [The WebAIM Million](https://webaim.org/projects/million/) (slide 8): February 2026: 95.9% of home pages with detected failures, ARIA and errors
- [Directive (EU) 2019/882](https://eur-lex.europa.eu/eli/dir/2019/882/oj) (slide 11): The text of the EAA, with the micro-enterprise exemption in article 4
- [EU richtlijn 2019/882 voor B2C (EAA)](https://www.vlaanderen.be/inter/overheden-en-professionelen/werken-aan-digitale-toegankelijkheid/eu-richtlijn-2019-882-voor-b2c-eaa) (slide 12): Belgian transposition, micro-enterprises until 28 June 2030
- [How to Meet WCAG (Quick Reference)](https://www.w3.org/WAI/WCAG22/quickref/) (slide 13)
- [Understanding page](https://www.w3.org/WAI/WCAG22/Understanding/focus-visible.html) (slide 14)
- [Accessibility features reference](https://developer.chrome.com/docs/devtools/accessibility/reference) (slide 18): The Accessibility pane in Chrome DevTools
- [MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/Heading_Elements) (slide 19)
- [button](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/button) (slide 21)
- [Images tutorial: decision tree](https://www.w3.org/WAI/tutorials/images/decision-tree/) (slide 26)
- [Alternative text](https://webaim.org/techniques/alttext/) (slide 26): WebAIM
- [Images tutorial: complex images](https://www.w3.org/WAI/tutorials/images/complex/) (slide 29)
- [label](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/label) (slide 32)
- [aria-describedby](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-describedby) (slide 34)
- [Forms tutorial](https://www.w3.org/WAI/tutorials/forms/) (slide 34)
- [autocomplete](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Attributes/autocomplete) (slide 36)
- [:focus-visible](https://developer.mozilla.org/en-US/docs/Web/CSS/:focus-visible) (slide 40)
- [Skip navigation links](https://webaim.org/techniques/skipnav/) (slide 42): WebAIM
- [Contrast checker](https://webaim.org/resources/contrastchecker/) (slide 46): WebAIM
- [Understanding page](https://www.w3.org/WAI/WCAG22/Understanding/contrast-minimum.html) (slide 46)
- [Colour vision in the workplace](https://www.theiet.org/impact-society/factfiles/engineering-safety-factfiles/colour-vision-in-the-workplace/) (slide 48): IET: about 1 in 12 men and 1 in 200 women
- [1.4.1](https://www.w3.org/WAI/WCAG22/Understanding/use-of-color.html) (slide 48)
- [1.4.10](https://www.w3.org/WAI/WCAG22/Understanding/reflow.html) (slide 49)
- [your CSS reads it](https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-reduced-motion) (slide 50)
- [Dark mode](https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-color-scheme) (slide 50)
- [Using ARIA](https://www.w3.org/TR/using-aria/) (slide 53): The first rule of ARIA
- [ARIA Authoring Practices Guide](https://www.w3.org/WAI/ARIA/apg/) (slide 55)
- [MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/dialog) (slide 57)
- [MDN](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Guides/Live_regions) (slide 60)
- [Lighthouse accessibility scoring](https://developer.chrome.com/docs/lighthouse/accessibility/scoring) (slide 62)
- [axe DevTools](https://www.deque.com/axe/devtools/) (slide 62)
- [WAVE](https://wave.webaim.org/) (slide 62)
- [Automated testing study identifies 57 percent of digital accessibility issues](https://www.deque.com/blog/automated-testing-study-identifies-57-percent-of-digital-accessibility-issues/) (slide 62): Deque, 2021
- [VoiceOver User Guide for Mac](https://support.apple.com/guide/voiceover/welcome/mac) (slide 64)
- [NVDA](https://www.nvaccess.org/) (slide 64)
- [Accessibility statement generator](https://www.w3.org/WAI/planning/statements/generator/) (slide 66): W3C

### Sources

- [WCAG 2.2](https://www.w3.org/TR/WCAG22/): The success criteria and their levels
- [Understanding conformance](https://www.w3.org/WAI/WCAG22/Understanding/conformance): Levels A, AA and AAA, and why AAA is not a policy for whole sites
- [WCAG 3 introduction](https://www.w3.org/WAI/standards-guidelines/wcag/wcag3-intro/): Still a working draft
- [European Accessibility Act](https://commission.europa.eu/strategy-and-policy/policies/justice-and-fundamental-rights/disability/union-equality-strategy-rights-persons-disabilities-2021-2030/european-accessibility-act_en): European Commission
- [FTC approves final order requiring accessiBe to pay $1 million](https://www.ftc.gov/news-events/news/press-releases/2025/04/ftc-approves-final-order-requiring-accessibe-pay-1-million)
- [Accessibility on MDN](https://developer.mozilla.org/en-US/docs/Web/Accessibility)
- [NVDA key commands](https://download.nvaccess.org/documentation/keyCommands.html)
