Since lesson 2.1.2, you’ve been building the Kladde so that everybody can use it: headings in order, landmarks, a label on every field, error messages as text, a visible focus. So far that happened by rules you got to know piece by piece. This lesson brings the framework behind them: the Web Content Accessibility Guidelines (WCAG) 2.2, the W3C’s standard for accessible web content. And you learn to check a page the way people do who depend on accessibility: with the keyboard alone, with a screen reader and with tools that find some of the defects automatically.
At the end, there’s a check report for the Kladde in docs/accessibility.md. From now on, every pull request that changes a page
checks it by the same procedure.
Who needs accessibility
Accessibility means: A page works for people with very different needs. Some examples:
- Blind people listen to the page with a screen reader, a program that reads aloud what’s on the screen and is controlled with the keyboard. They need alternative texts, headings, landmarks and labels.
- People with low vision magnify the page a lot or set a larger font. They need enough contrast and a page that doesn’t slide sideways at 400 percent zoom.
- People with a color vision deficiency, about one in twelve men, have trouble telling red and green apart. An error message that consists only of a red border is something they don’t see.
- People who can’t use a mouse, for example because of paralysis or a tremor, operate everything with the keyboard, with voice control or with a single switch. They need a page that can be operated completely with the keyboard.
- People with cognitive impairments need clear language, unambiguous labels and error messages that say what to do.
On top of that come situations in which it’s like that for everyone: sun on the phone, an arm in a cast, a loud train without headphones. So accessibility doesn’t help only a small group. A page that works with the keyboard usually also has a clear order, and a page with good contrast is easier for everybody to read.
WCAG 2.2 in brief
WCAG 2.2 is a W3C recommendation. It sorts its requirements under four principles:
| Principle | The question behind it | Examples in the Kladde |
|---|---|---|
| Perceivable | Can it be perceived, with whichever sense? | alternative texts, contrast, never color alone |
| Operable | Can it be operated, even without a mouse? | keyboard, visible focus, targets that are large enough |
| Understandable | Are content and operation understood? | the page’s language, labels, error messages with a hint |
| Robust | Do browsers and assistive technologies understand the code? | valid HTML, every control with name and role |
Below the principles are guidelines, and below them the actual success criteria. Each has a number, an English name and a level:
- A is the minimum. Whoever misses an A criterion shuts people out completely, for example with a button the keyboard can’t reach.
- AA is the usual target. Laws and standards refer to this level. “Conformant to level AA” means: all criteria of levels A and AA are met.
- AAA goes further, for example with a contrast of 7:1. The W3C itself advises against requiring AAA for entire websites, because not all content can be designed that way.
The Kladde’s target is WCAG 2.2, level AA. That’s been in the house rules since K2. These criteria affect the Kladde most:
| No. | Name | Level | What it’s about |
|---|---|---|---|
| 1.1.1 | Non-text Content | A | Images have a fitting alternative text, decorative images alt="" |
| 1.3.1 | Info and Relationships | A | Headings, lists, tables, labels are in the code, not only in the look |
| 1.3.5 | Identify Input Purpose | AA | Fields for information about the person carry autocomplete |
| 1.4.1 | Use of Color | A | Color is never the only distinguishing feature |
| 1.4.3 | Contrast (Minimum) | AA | Text has a contrast of at least 4.5:1, large text 3:1 |
| 1.4.4 | Resize Text | AA | Text can be enlarged to 200 percent without losing anything |
| 1.4.10 | Reflow | AA | no horizontal scrolling at a width of 320 CSS pixels |
| 1.4.11 | Non-text Contrast | AA | Borders of fields, focus rings and important icons have 3:1 |
| 2.1.1 | Keyboard | A | everything can be operated with the keyboard |
| 2.1.2 | No Keyboard Trap | A | the focus gets out again everywhere |
| 2.4.1 | Bypass Blocks | A | you can skip recurring blocks such as the navigation |
| 2.4.2 | Page Titled | A | every page has an apt title |
| 2.4.3 | Focus Order | A | the focus moves in a sensible order |
| 2.4.4 | Link Purpose (In Context) | A | a link says where it leads |
| 2.4.6 | Headings and Labels | AA | headings and labels describe what follows |
| 2.4.7 | Focus Visible | AA | you can see where the focus is |
| 2.4.11 | Focus Not Obscured (Minimum) | AA | nothing hides the element with the focus entirely |
| 2.5.8 | Target Size (Minimum) | AA | Targets are at least 24 by 24 CSS pixels or have enough spacing |
| 3.1.1 | Language of Page | A | lang on the html element |
| 3.3.1 | Error Identification | A | an error is named as text |
| 3.3.2 | Labels or Instructions | A | every field has a label or instructions |
| 3.3.3 | Error Suggestion | AA | the message says how to do it right |
| 4.1.2 | Name, Role, Value | A | every control has name, role and state in the code |
New in version 2.2 are, among others, 2.4.11 and 2.5.8 from this table, plus criteria for help in the same place, for information you don’t have to enter twice, and for sign-ins without puzzles. The old criterion 4.1.1 “Parsing” was dropped because browsers now repair broken HTML uniformly. You still need the validator from lesson 2.1.2: Valid HTML is the foundation on which everything else builds.
For each criterion, read the W3C’s “Understanding” page when you need to (link at the end). It says what’s meant, why, and with which techniques you meet it.
Keyboard and visible focus
With the keyboard, you operate a page with a few keys:
| Key | Effect |
|---|---|
| Tab, Shift+Tab | to the next or previous control |
| Enter | follow a link, trigger a button, submit a form |
| Space | trigger a button, check or uncheck a checkbox; otherwise it scrolls the page down |
| Arrow keys | choose within a group of radio buttons, scroll in a select list |
| Esc | close something that’s open, such as a select list |
Five rules follow:
- Everything you can click is also reached and operated with the keyboard (2.1.1). Links, buttons and form fields can do that by
themselves. A
divwith a click event can’t. That’s why you always take the fitting element. - The focus follows the order in the source (2.4.3). If CSS arranges the elements differently, for example with
orderor grid, the focus seems to jump back and forth wildly. Never set atabindexgreater than 0. It sends the element to the start of the order, before everything else. Only self-built controls needtabindex="0", and you avoid those.tabindex="-1"makes an element focusable by script without putting it into the Tab order; that comes up in module 2.5. - Trapped nowhere (2.1.2). You get out of every element with Tab or Esc.
- The focus is always visible (2.4.7) and not hidden (2.4.11), for example by a fixed header that stays put while scrolling. Since
lesson 2.1.4, the Kladde draws a three-pixel-wide ring around the element with the focus.
outline: nonewithout a replacement is the defect you’ll look for in exercise 2. - Targets are large enough (2.5.8): at least 24 by 24 CSS pixels, or smaller with so much spacing that a circle of 24 pixels around each target touches no other. The Kladde’s navigation links are a good 40 pixels tall thanks to padding. Its radio buttons are only 20 pixels in size but stand 32 pixels apart, and their label also checks them when clicked.
A page often consists of blocks that return on every page: logo and navigation. Anyone working with the keyboard has to press Tab
four times on every page of the Kladde before reaching the content. For screen readers, landmarks and headings are enough to skip this
block (2.4.1). Anyone working with the keyboard without a screen reader gets nothing from that. For them there’s the skip link: a
link “Skip to content” as the first element of the page, which only becomes visible when it has the focus and points to the main.
The Kladde gets it in module 2.5 with its layout, because there the header is in one single place instead of four files.
Contrast and color
Text has to stand out from the background. WCAG measures that with the contrast ratio of two colors: from 1:1 (same color) to 21:1 (black on white). Required are:
- 4.5:1 for text (1.4.3), 3:1 for large text: from 24 pixels, or from just under 19 pixels in bold. Exempt are text in a logo, pure decoration and disabled controls.
- 3:1 for controls and graphics (1.4.11): the border of an input field against its surroundings, the focus ring, an icon that means something. A divider that only structures doesn’t count.
- Color never alone (1.4.1): A link in running text is also underlined, an error also has a text.
You calculate the ratio from the relative luminance L of both colors: (lighter L + 0.05) / (darker L + 0.05). Luminance weights
red, green and blue the way the eye perceives them; green counts most. The following program calculates it, as announced in lesson
2.1.4. Create contrast.cs in your practice folder k2-practice:
// contrast.cs: calculates contrast ratios according to WCAG 2.2
using System.Globalization;
if (args.Length == 2)
{
double ratio = Contrast.Ratio(args[0], args[1]);
Console.WriteLine($"{args[0]} on {args[1]}: {Contrast.Format(ratio)}");
Console.WriteLine($" Text (at least 4.5:1): {(ratio >= 4.5 ? "met" : "too low")}");
Console.WriteLine($" large text, controls (at least 3:1): {(ratio >= 3 ? "met" : "too low")}");
return 0;
}
if (args.Length == 1 && File.Exists(args[0]))
{
int failures = 0;
foreach (string line in File.ReadAllLines(args[0]))
{
if (line.Trim().Length == 0 || line.TrimStart().StartsWith("//"))
{
continue;
}
string[] parts = line.Split(';', StringSplitOptions.TrimEntries);
double ratio = Contrast.Ratio(parts[1], parts[2]);
double required = double.Parse(parts[3], CultureInfo.InvariantCulture);
bool ok = ratio >= required;
if (!ok) failures++;
Console.WriteLine($"{(ok ? "ok " : "LOW ")} {Contrast.Format(ratio),8} (min. {required.ToString("0.0", CultureInfo.GetCultureInfo("en-US"))}:1) {parts[0]}");
}
Console.WriteLine(failures == 0 ? "All pairs meet their minimum." : $"{failures} pair(s) below the minimum.");
return failures == 0 ? 0 : 1;
}
Console.WriteLine("Usage: dotnet run contrast.cs -- <foreground> <background> (e.g. 4a5552 fbfaf7)");
Console.WriteLine(" dotnet run contrast.cs -- <file> (per line: name; foreground; background; minimum)");
return 2;
static class Contrast
{
// The contrast ratio according to WCAG: (lighter luminance + 0.05) / (darker luminance + 0.05), from 1:1 to 21:1.
public static double Ratio(string foreground, string background)
{
double a = Luminance(foreground);
double b = Luminance(background);
return (Math.Max(a, b) + 0.05) / (Math.Min(a, b) + 0.05);
}
// Rounded down, never up: 4.499 is not a 4.5.
public static string Format(double ratio) =>
(Math.Floor(ratio * 100) / 100).ToString("0.00", CultureInfo.GetCultureInfo("en-US")) + ":1";
// The relative luminance of a color like 1f5f5b or #1f5f5b, from 0 (black) to 1 (white).
private static double Luminance(string color)
{
string hex = color.TrimStart('#');
if (hex.Length != 6)
{
throw new FormatException($"Not a color with six digits: {color}");
}
double r = Channel(hex[0..2]);
double g = Channel(hex[2..4]);
double b = Channel(hex[4..6]);
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
private static double Channel(string pair)
{
double c = int.Parse(pair, NumberStyles.HexNumber, CultureInfo.InvariantCulture) / 255.0;
return c <= 0.04045 ? c / 12.92 : Math.Pow((c + 0.055) / 1.055, 2.4);
}
}
Only the class at the end is new: A file-based app may contain its own types after the top-level statements. Channel converts a color
value from 0 to 255 into its linear share, as WCAG prescribes. Try the Kladde’s muted text on its surface:
dotnet run contrast.cs -- 4a5552 fbfaf7
4a5552 on fbfaf7: 7.41:1
Text (at least 4.5:1): met
large text, controls (at least 3:1): met
Leave off the # in front of the colors. In PowerShell and in bash, a # starts a comment, and the rest of the line wouldn’t even
reach the program. With a file as the argument, the program checks many pairs at once and ends with exit code 1 as soon as one is too
low. That way it fits into a check script, like outline.cs from lesson 2.1.2.
The browser helps too: In the DevTools, the color picker in Styles shows the contrast ratio next to every text color, and the Inspect tool shows it in the overlay as soon as you point at a text. Under Rendering, “Emulate vision deficiencies” shows the page the way people with a color vision deficiency see it, for example with no color at all (“Achromatopsia”).
Name, role and state
A screen reader doesn’t read the page’s pixels. It asks the browser, and the browser builds a second tree from the DOM, the accessibility tree. In it, every element that means something has three things:
- a role: link, button, input field, heading of level 2, table;
- a name: the text of a link, the label of a field, the alternative text of an image;
- states and properties: checked, required, invalid, current page.
From these three comes what the screen reader says: “Email, edit, required, We’ll send the confirmation to this address.” That’s
criterion 4.1.2. In Edge, you see the tree in Elements on the Accessibility tab next to Styles: at the top the element’s
position in the tree, below it under Computed Properties name, role and states. An element with the role generic and no name that
you’re supposed to click anyway is immediately recognizable there as a problem.
The name comes from fixed sources, in a fixed order: aria-labelledby, aria-label, then the fitting HTML (a field’s label, the
content of a link or button, an image’s alt), finally title and, for fields, the placeholder. That has a consequence you’ll
see in exercise 2: A field with a placeholder and without a label has a name for the browser. The screen reader reads it aloud, and
the automated check reports nothing. But a sighted person loses the label as soon as they type (3.3.2).
ARIA only where HTML isn’t enough
ARIA (Accessible Rich Internet Applications) is a W3C addition to HTML. With attributes like role, aria-label or
aria-invalid, it changes what’s in the accessibility tree. You already know four of them. The first rule from the W3C’s guide to
using ARIA reads, roughly: If there’s an HTML element or attribute that already brings the meaning and behavior, use that instead of
reinterpreting another element with ARIA.
This comparison shows why:
<!-- not like this -->
<div class="button" role="button">Save</div>
<!-- like this -->
<button type="submit">Save</button>
role="button" tells the screen reader “button”. That’s all it does: The div gets no focus, Enter and Space trigger nothing, and
without script it doesn’t submit a form either. A button can do all of that by itself. ARIA only changes what is said, never what
happens. Three more rules follow:
- No ARIA is better than wrong ARIA. A wrong role or a forgotten state misleads people who rely on it.
aria-labelreplaces the visible text for assistive technologies. If it deviates from it, voice input no longer understands “Click Save” if the button is called “Back up data” for assistive technologies.aria-hidden="true"never on anything that can receive the focus. The element is then invisible to the screen reader, but the keyboard lands on it anyway.
The Kladde uses ARIA exactly where HTML has nothing of its own:
| Attribute | Where in the Kladde | Why ARIA |
|---|---|---|
aria-label |
nav aria-label="Main navigation" |
HTML has no name for one of several navigations |
aria-current="page" |
link of the current page | HTML has no state “current page” |
aria-describedby |
hint and error message on the field | label gives the name; there’s no HTML for an additional description |
aria-invalid="true" |
field with an error on appointment-error.html |
the server found the error, not the browser |
In module 2.2, aria-live is added, with which a script has a change announced.
Checking in four ways
No single check finds everything. That’s why you check every page in four ways, and in this order: What the keyboard and the screen reader show is what people experience. The tools then find what’s easy to overlook.
With the keyboard
The keyboard check from the definition of done, now in detail. Put the mouse away and reload the page:
- Press Tab until you’re back at the top in the address bar. Does the focus stop at every link, every field and every button? Is it clearly visible at every stop?
- Is the order the reading order: header, navigation, content from top to bottom, footer?
- Can you get all the way back with Shift+Tab without getting stuck anywhere?
- Trigger every element: links with Enter, buttons with Enter and Space, checkboxes with Space, a group of radio buttons with the arrow keys. By the way, a group is only a single stop; inside it, the arrows choose.
- Fill out every form completely once and submit it.
- Zoom in with Ctrl and plus to 400 percent. In a window of 1280 pixels, that’s 320 CSS pixels (1.4.10). Is everything still there, with no horizontal scrolling?
Depending on the browser, a date field can have several stops, one each for day, month and year. That isn’t a defect of the page.
With a screen reader
On Windows, Narrator is already built in; for free, NVDA is added, a free screen reader that many blind people use. On the Mac, it’s VoiceOver, best with Safari. The most important commands:
| Narrator (Windows) | NVDA (Windows) | VoiceOver (macOS) | |
|---|---|---|---|
| on and off | Ctrl+Windows+Enter | Ctrl+Alt+N (starts) | Cmd+F5 |
| special key | Caps Lock or Insert | Insert | Ctrl+Option (“VO”) |
| stop speaking | Ctrl | Ctrl | Ctrl |
| next heading, landmark, link, field, table, button | H, D, K, F, T, B in scan mode | H, D, K, F, T, B in browse mode | VO+U opens the rotor with these lists |
| lists | special key+F5 landmarks, +F6 headings, +F7 links | NVDA+F7 elements list | VO+U |
With Shift, the letters jump backward instead of forward. You switch Narrator’s scan mode with the special key and Space; in Edge it’s usually already on. In an input field, it switches itself off so that the letters go into the field instead of jumping.
The first attempt feels confusing. Set the speaking rate slower and try it first with a page you know. While checking, pay attention to these questions: Is the page’s title read first? Do the headings, as a list, make a table of contents? Does every field say its label, its kind and whether it’s required? On an error page, do you hear that something is wrong, and what? Is the screen reader silent for decorative images?
With the Edge DevTools
You know the DevTools from lesson 2.1.5. For accessibility, five places count:
- Issues (via the Command Menu): In the Accessibility section are, among other things, missing alternative texts, fields without labels and too little contrast.
- Elements, Accessibility tab: accessibility tree, ARIA attributes, name and role; plus the Source Order Viewer, which lays the order in the source over the page as numbers.
- Inspect (Ctrl+Shift+C): When you point at an element, the overlay names under Accessibility the contrast, name, role and whether the keyboard reaches it.
- Rendering: simulate color vision deficiencies and the dark scheme with “Emulate CSS media feature prefers-color-scheme”.
- Lighthouse with the category Accessibility: a report with a score and the defects found, more on that in lesson 2.1.8.
With Accessibility Insights for Web
Accessibility Insights for Web is a free extension from Microsoft for Edge and Chrome. You install it from your browser’s add-on store and open it on a page via its icon or with Alt+Shift+K. For every pull request, FastPass is enough:
- Automated checks check the page against dozens of rules. Behind it is axe-core, a free library for such checks, the same one Lighthouse uses.
- Tab stops: You go through the page with Tab, and the tool draws every stop with a number and a line. Missing stops and traps stand out that way.
- Needs review: Places the automation can’t decide, you decide.
For a complete check, there’s the Assessment, which leads step by step through all the requirements. It takes hours and belongs at the end of a milestone, not in every pull request.
What the automation finds and what it doesn’t
Automated checks reliably find what can be read off the code: an image without alt, a field without any name, too little contrast in
text, a missing lang, a duplicate id, a button without a name. They don’t find:
- whether an alternative text or a label fits;
- whether the focus can be seen and the order makes sense;
- whether an error message helps or just says “Invalid”;
- whether information is conveyed only through color;
- whether the page, read aloud, is understandable.
The Accessibility Insights documentation says it itself: Most problems are found only by a manual check. A green automated check is therefore necessary but never enough.
When accessibility is mandatory
This section is an overview, not legal advice. Public bodies have to make their websites and apps accessible; in Germany, for the federal government that’s regulated by the Disability Equality Act (BGG) and the BITV 2.0, for the states and municipalities by laws of their own. For companies, the Barrierefreiheitsstärkungsgesetz (BFSG, the German accessibility act) has applied since June 28, 2025. It implements an EU directive, the European Accessibility Act, and covers certain products and services for consumers, including e-commerce services. Microenterprises that offer services are exempt. The European standard EN 301 549, on which such rules for web content are based, requires the WCAG success criteria at level AA: the older version V3.2.1 those of WCAG 2.1, the new version V4.1.1 those of WCAG 2.2. Whoever meets WCAG 2.2 AA thereby also meets the older version. Whether and how an obligation applies to a particular offering and which version counts for it is a legal question that legal advice settles.
Exercise 1: The Kladde’s contrasts
In k2-practice, create a file contrasts.txt. Each line names a color pair of your Kladde with its minimum value; lines with // are
comments. This is how it begins:
// Kladde, light scheme: text needs 4.5, controls and focus 3
Text on surface; 1d2422; fbfaf7; 4.5
Text on card and field; 1d2422; ffffff; 4.5
Muted text on surface; 4a5552; fbfaf7; 4.5
- From
_tokens.scss, add all pairs that occur on the four pages, for the light and the dark scheme: links on surface and card, error text, the text on the button, and, with a minimum of 3, a field’s border, the error border, the focus ring on surface and card, and the button on the surface. Use your own colors if you chose different ones in lesson 2.1.4. - Run
dotnet run contrast.cs -- contrasts.txtand read the exit code. If a pair is too weak, change the token in_tokens.scssas little as possible until it’s enough. - Check
c9cfcconfbfaf7individually, the token--color-borderon the surface. The result is weak. Is that a defect? - Check
9aa19eonffffff. What would this shade of gray be good enough for?
Solution 1
With the colors from lesson 2.1.4, the complete file reads:
// Kladde, light scheme: text needs 4.5, controls and focus 3
Text on surface; 1d2422; fbfaf7; 4.5
Text on card and field; 1d2422; ffffff; 4.5
Muted text on surface; 4a5552; fbfaf7; 4.5
Link on surface; 1a5c8a; fbfaf7; 4.5
Link on card; 1a5c8a; ffffff; 4.5
Error text on surface; a4262c; fbfaf7; 4.5
Text on the button; ffffff; 1f5f5b; 4.5
Border of a field; 4a5552; ffffff; 3
Error border of a field; a4262c; ffffff; 3
Focus ring on surface; b35c00; fbfaf7; 3
Focus ring on card; b35c00; ffffff; 3
Button on surface; 1f5f5b; fbfaf7; 3
// Kladde, dark scheme
Text on surface; e6ebe9; 141a19; 4.5
Text on card and field; e6ebe9; 1d2524; 4.5
Muted text on surface; b4bfbb; 141a19; 4.5
Link on surface; 8cc4f0; 141a19; 4.5
Link on card; 8cc4f0; 1d2524; 4.5
Error text on surface; ff8a8f; 141a19; 4.5
Text on the button; 0d1413; 7cc4bd; 4.5
Border of a field; b4bfbb; 1d2524; 3
Error border of a field; ff8a8f; 1d2524; 3
Focus ring on surface; ffb366; 141a19; 3
Focus ring on card; ffb366; 1d2524; 3
Button on surface; 7cc4bd; 141a19; 3
ok 15.15:1 (min. 4.5:1) Text on surface
ok 15.81:1 (min. 4.5:1) Text on card and field
ok 7.41:1 (min. 4.5:1) Muted text on surface
ok 6.83:1 (min. 4.5:1) Link on surface
ok 7.13:1 (min. 4.5:1) Link on card
ok 6.95:1 (min. 4.5:1) Error text on surface
ok 7.37:1 (min. 4.5:1) Text on the button
ok 7.74:1 (min. 3.0:1) Border of a field
ok 7.26:1 (min. 3.0:1) Error border of a field
ok 4.52:1 (min. 3.0:1) Focus ring on surface
ok 4.72:1 (min. 3.0:1) Focus ring on card
ok 7.06:1 (min. 3.0:1) Button on surface
ok 14.62:1 (min. 4.5:1) Text on surface
ok 12.98:1 (min. 4.5:1) Text on card and field
ok 9.32:1 (min. 4.5:1) Muted text on surface
ok 9.45:1 (min. 4.5:1) Link on surface
ok 8.39:1 (min. 4.5:1) Link on card
ok 7.79:1 (min. 4.5:1) Error text on surface
ok 9.30:1 (min. 4.5:1) Text on the button
ok 8.27:1 (min. 3.0:1) Border of a field
ok 6.91:1 (min. 3.0:1) Error border of a field
ok 9.98:1 (min. 3.0:1) Focus ring on surface
ok 8.85:1 (min. 3.0:1) Focus ring on card
ok 8.80:1 (min. 3.0:1) Button on surface
All pairs meet their minimum.
The exit code is 0. That’s the promise from lesson 2.1.4: The colors of the solution already meet everything. If you have colors of your own, the most common outlier is the muted text: A gray that looks “calm” is quickly below 4.5:1.
On question 3: c9cfcc on fbfaf7 gives only 1.51:1. That is not a defect as long as the token only draws lines that structure:
the borders of the cards and groups, the lines under the table rows. A field, on the other hand, has to be recognized as a field in
order to be used. That’s why the fields have used --color-muted as their border since lesson 2.1.4, with 7.74:1. If they had
--color-border, it would be a violation of 1.4.11.
On question 4: 9aa19e on white gives 2.63:1. That’s good enough for nothing, neither for text nor for a control. You’ll meet exactly
this shade of gray again in exercise 2.
Exercise 2: Five barriers
A colleague built the following page “Arrange a callback” in the Kladde’s style. It has five barriers. Create barriers.cs in
k2-practice. Copy the file without reading it, like the workshop from lesson 2.1.5, start it with dotnet run barriers.cs and
open http://localhost:5080/.
// barriers.cs: a page with five barriers, to find with keyboard, screen reader and a checking tool
#:sdk Microsoft.NET.Sdk.Web
#:property PublishAot=false
using System.Net;
var builder = WebApplication.CreateBuilder();
builder.Logging.SetMinimumLevel(LogLevel.Warning);
var app = builder.Build();
const string Css = """
*, *::before, *::after { box-sizing: border-box; }
body {
margin: 0;
font-family: system-ui, "Segoe UI", Roboto, sans-serif;
line-height: 1.5;
color: #1d2422;
background: #ffffff;
}
:focus { outline: none; }
.site-header { padding: 1rem; border-bottom: 1px solid #c9cfcc; }
.site-header a { color: #1a5c8a; font-weight: 700; }
main { max-width: 40rem; margin: 0 auto; padding: 1.5rem 1rem; }
.hint { color: #9aa19e; }
.field { margin-bottom: 1rem; }
.field label { display: block; margin-bottom: 0.25rem; font-weight: 600; }
.field input {
width: 100%;
padding: 0.5rem;
font: inherit;
border: 1px solid #4a5552;
border-radius: 0.375rem;
}
.field .invalid { border: 2px solid #a4262c; }
button {
display: inline-flex;
padding: 0.5rem 1.5rem;
color: #ffffff;
background: #1f5f5b;
border: 0;
border-radius: 0.375rem;
cursor: pointer;
}
""";
app.MapGet("/", () => Html(Page(name: "", phone: "", nameMissing: false)));
app.MapGet("/barriers.css", () => Results.Content(Css, "text/css; charset=utf-8"));
app.MapPost("/callback", async (HttpRequest request, HttpResponse response) =>
{
var form = await request.ReadFormAsync();
string name = form["name"].ToString().Trim();
string phone = form["phone"].ToString().Trim();
if (name.Length == 0)
{
return Html(Page(name, phone, nameMissing: true), StatusCodes.Status400BadRequest);
}
response.Headers.Location = "/thanks";
return Results.StatusCode(StatusCodes.Status303SeeOther);
});
app.MapGet("/thanks", () => Html("""
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Thanks – Kladde</title>
<link rel="stylesheet" href="/barriers.css">
</head>
<body>
<main>
<h1>Thanks</h1>
<p>We’ll call you back. <a href="/">Arrange another callback</a></p>
</main>
</body>
</html>
"""));
Console.WriteLine("The page with the barriers is running on http://localhost:5080. Quit with Ctrl+C.");
app.Run("http://localhost:5080");
static IResult Html(string html, int status = StatusCodes.Status200OK) =>
Results.Content(html, "text/html; charset=utf-8", statusCode: status);
// The page with the form; the input comes back encoded into the fields.
static string Page(string name, string phone, bool nameMissing) => $$"""
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Arrange a callback – Kladde</title>
<link rel="stylesheet" href="/barriers.css">
</head>
<body>
<header class="site-header">
<a href="/">Kladde</a>
</header>
<main>
<h1>Arrange a callback</h1>
<p class="hint">We call back on weekdays between 9:00 and 17:00.</p>
<form action="/callback" method="post">
<div class="field">
<label for="name">Name</label>
<input type="text" id="name" name="name" autocomplete="name" value="{{WebUtility.HtmlEncode(name)}}"{{(nameMissing ? " class=\"invalid\"" : "")}}>
</div>
<div class="field">
<input type="tel" id="phone" name="phone" autocomplete="tel" placeholder="Phone number" value="{{WebUtility.HtmlEncode(phone)}}">
</div>
<button type="submit">
<svg width="24" height="24" viewBox="0 0 24 24"><path d="M4 12h14m-6-6 6 6-6 6" fill="none" stroke="currentColor" stroke-width="2"/></svg>
</button>
</form>
</main>
</body>
</html>
""";
$$""" is a raw string in which values are inserted with double curly braces; single braces stay text. WebUtility.HtmlEncode encodes
the input before it goes back into the HTML. Why that’s necessary, you’ll learn in module 2.6.
Proceed in this order and note every find in a table with the columns defect, found with, success criterion and fix:
- Keyboard: Operate the page without a mouse, as described above. Submit the form once without a name.
- Screen reader: Start Narrator, NVDA or VoiceOver and operate the page again, with and without a name. Don’t look at the screen while doing so.
- Eyes: Fill out the phone field and watch what happens. Under Rendering, switch “Emulate vision deficiencies” to “Achromatopsia” and submit the form once more without a name.
- Automation: FastPass in Accessibility Insights, or Lighthouse with the category Accessibility.
- Only now read the code, fix all five barriers and check again in all four ways.
The exercise is checked when the automated check reports nothing anymore, the focus is visible at every stop, the button has a name when read aloud, both fields have a visible label, and submitting without a name says audibly, and visibly without color, what’s missing.
Solution 2
| No. | Defect | Found with | Criterion | Fix |
|---|---|---|---|---|
| 1 | no visible focus, :focus { outline: none; } |
keyboard | 2.4.7 Focus Visible | a ring with :focus-visible as in the Kladde |
| 2 | hint in #9aa19e on white, 2.63:1 |
automated check, contrast.cs |
1.4.3 Contrast (Minimum) | #4a5552, 7.74:1 |
| 3 | phone field with only a placeholder | eyes: the label disappears when typing | 3.3.2 Labels or Instructions | a label “Phone (optional)”, no placeholder |
| 4 | button with only an arrow, without a name | automated check, screen reader | 4.1.2 Name, Role, Value | visible text “Request callback”, arrow with aria-hidden="true" |
| 5 | error only as a red border | eyes without color, screen reader | 1.4.1 Use of Color, 3.3.1 Error Identification | message as text, aria-invalid and aria-describedby, title with “Error:” |
What the individual ways show:
- Keyboard: The stops are the link “Kladde”, the name field, the phone field and the button. On the link and the button, nothing can be seen; in the fields, only the blinking cursor reveals where you are. After submitting without a name, the name field has a red border; otherwise nothing changes.
- Screen reader: The button is called only “button”. The phone field is read as “Phone number”, because the browser takes the placeholder as the name. After submitting without a name, you hear the same title as before and not a word about an error.
- Eyes: At the first typed character, “Phone number” disappears, and whoever scrolls back no longer knows what belonged in the field. Without color, the red border is a gray one, barely thicker than the others.
- Automation: Exactly two defects are reported: too little contrast in the hint (rule
color-contrast, 2.63 instead of 4.5) and a button without a recognizable name (rulebutton-name). For this lesson, that was checked with Lighthouse, which, like FastPass, is based on axe-core. The invisible focus, the placeholder and the error only in color are reported by no automation: For the placeholder, there is a name, and whether a border is visible or something has only color is something a program can’t judge.
So three of the five barriers are found only by a person. The fix in the stylesheet:
:focus-visible { outline: 3px solid #b35c00; outline-offset: 2px; }
.hint { color: #4a5552; }
.field [aria-invalid="true"] { border: 2px solid #a4262c; }
.field-error { margin: 0.25rem 0 0; color: #a4262c; font-weight: 700; }
button {
display: inline-flex;
align-items: center;
gap: 0.5rem;
font: inherit;
font-weight: 700;
padding: 0.5rem 1.5rem;
color: #ffffff;
background: #1f5f5b;
border: 0;
border-radius: 0.375rem;
cursor: pointer;
}
:focus { outline: none; } and .field .invalid { … } go away. And the method Page, which now has a body because it prepares three
parts:
// The page with the form; the input comes back encoded into the fields.
static string Page(string name, string phone, bool nameMissing)
{
string title = nameMissing ? "Error: Arrange a callback – Kladde" : "Arrange a callback – Kladde";
string nameState = nameMissing ? " aria-invalid=\"true\" aria-describedby=\"name-error\"" : "";
string nameError = nameMissing ? "<p class=\"field-error\" id=\"name-error\">Please enter your name.</p>" : "";
return $$"""
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{title}}</title>
<link rel="stylesheet" href="/barriers.css">
</head>
<body>
<header class="site-header">
<a href="/">Kladde</a>
</header>
<main>
<h1>Arrange a callback</h1>
<p class="hint">We call back on weekdays between 9:00 and 17:00.</p>
<form action="/callback" method="post">
<div class="field">
<label for="name">Name</label>
<input type="text" id="name" name="name" autocomplete="name" value="{{WebUtility.HtmlEncode(name)}}"{{nameState}}>
{{nameError}}
</div>
<div class="field">
<label for="phone">Phone (optional)</label>
<input type="tel" id="phone" name="phone" autocomplete="tel" value="{{WebUtility.HtmlEncode(phone)}}">
</div>
<button type="submit">
Request callback
<svg aria-hidden="true" width="24" height="24" viewBox="0 0 24 24"><path d="M4 12h14m-6-6 6 6-6 6" fill="none" stroke="currentColor" stroke-width="2"/></svg>
</button>
</form>
</main>
</body>
</html>
""";
}
After the repair, the automated check reports nothing anymore on both states of the page. For the name field after submitting without a name, the accessibility tree shows: name “Name”, description “Please enter your name.”, invalid. When loading, the screen reader reads “Error: Arrange a callback – Kladde” and the message at the field, and the button is called “Request callback”.
Two decisions that could be made differently:
- The arrow in the button you may keep.
aria-hidden="true"takes it out of the accessibility tree, because the text already says everything. A button with only an icon andaria-label="Request callback"would also be free of errors, but whoever can’t interpret the icon doesn’t know what the button does. Visible text is the best label. - A placeholder as an example, such as
placeholder="555 0123", would be allowed beside thelabel. But it’s pale and disappears when typing; a hint under the field witharia-describedby, as in the Kladde, is better.
Exercise 3: The Kladde’s check report
Now check the four pages of your prototype. Start the Kladde with dotnet run --project src/Kladde.Web and go through every page in
all four ways:
- the keyboard check with all six steps, also at 400 percent zoom;
- a screen reader: Have it give you the headings and the landmarks of every page as a list, jump to the main content with D, fill out
the booking form and listen to
appointment-error.html; - FastPass on every page, in the light and in the dark scheme;
contrast.cswith yourcontrasts.txt.
Fix every defect you find. Record procedure, finding and fix in docs/accessibility.md: what was checked, with what, with which
result, what you changed, and what stays open. Extend the definition of done with the automated check.
Solution 3
With the prototype from the solutions of lessons 2.1.2 to 2.1.6, none of the four ways finds a violation. For this lesson, all four
pages were checked with Lighthouse, at the width of a phone: index.html and appointment.html in the light scheme, contacts.html
in the dark one and appointment-error.html in both schemes. Every time it said 100 points in the category Accessibility, and at 320
CSS pixels no page had horizontal scrolling. For you, it may be different, because your prototype is your own. These deviations come up
most often when checking prototypes of your own:
- a muted text or a hint with too little contrast, mostly in the dark scheme;
outline: nonesomewhere in the stylesheet, for example on the buttons, because the ring was annoying when clicking;- radio buttons without
fieldsetandlegend, so the question isn’t read aloud; - a field without “(optional)” although it isn’t required, or a requirement without
required; - an error message without
aria-describedby, which the screen reader doesn’t read at the field.
This is what docs/accessibility.md can look like:
# Accessibility of the Kladde
Target: WCAG 2.2, level AA. Every pull request that changes a page checks that page
by the procedure below. A complete check (Accessibility Insights, Assessment)
follows at the end of every milestone.
## Procedure
1. Keyboard check: all stops with Tab and Shift+Tab, focus always visible,
everything triggered with Enter or Space, trapped nowhere, 400 percent without
horizontal scrolling.
2. Screen reader (Narrator, NVDA or VoiceOver): title, headings,
landmarks, every field with label, kind and requirement, error page.
3. Accessibility Insights FastPass in the light and in the dark scheme.
4. dotnet run contrast.cs -- contrasts.txt ends with exit code 0.
## Check of the prototype
Checked on: <date>, by: <name>, with: Edge, Narrator, Accessibility Insights for Web
Pages: index.html, contacts.html, appointment.html, appointment-error.html
| Way | Result |
| --- | --- |
| Keyboard | passed on all four pages |
| Screen reader | passed |
| FastPass light and dark | no findings |
| Contrasts | 24 pairs, all met |
## Findings and fix
| Finding | Criterion | Fix |
| --- | --- | --- |
| (your findings, one line each; none: "none") | | |
## Open
- Four Tab stops lie before the content (logo, three links). With landmarks and
headings, 2.4.1 is met; a skip link comes with the layout in module 2.5.
- The date field has several Tab stops depending on the browser. Not a defect of the page.
The definition of done gets a second line in the section “Use”:
- Accessibility Insights FastPass reports nothing on every new or changed page,
in the light and in the dark scheme. Procedure: docs/accessibility.md
Commit docs/accessibility.md, the changed definition of done and, if you fixed something, the changed pages and stylesheets on a
branch of their own, such as accessibility, with a message like “Check the prototype against WCAG 2.2 AA and record the result”.
Write into the pull request’s description what you checked with what. The reviewer does the keyboard check once more themselves;
that’s the accessibility item on the review checklist from lesson 2.10.3.
contrast.cs and contrasts.txt stay in your practice folder. If you like, both may also go into the Kladde, for example to tools/:
Then every change to the colors recalculates in the same pull request.
The Kladde
After this lesson, the prototype has been checked against WCAG 2.2 AA: with no finding from the automated check, with a passed keyboard check and a check report that every later pull request can refer to. In module 2.5, the pages become Blazor components. The rules of this lesson come along: The check procedure stays the same, and in lesson 2.5.13 you check the finished pages once more.
What you can do now
- You explain the four principles of WCAG, the levels A, AA and AAA and why the Kladde aims for AA.
- You operate a page with the keyboard alone and recognize missing focus, a wrong order and traps.
- You recalculate contrasts, tell 4.5:1 for text from 3:1 for controls and never let color speak alone.
- You read name, role and state in the accessibility tree and use ARIA only where HTML has nothing of its own.
- You operate a page with a screen reader and check it with the Edge DevTools and Accessibility Insights for Web.
- You know that the automation finds only some of the defects, and you record a check in a report.
Read more
- Learn the basics of web accessibility: the Microsoft Learn module on tools and design for accessibility.
- Accessibility-testing features: which of the Edge DevTools’ tools check which part of accessibility, with a demo page for practice.
- Test accessibility using the Accessibility tab: accessibility tree, ARIA attributes and computed properties.
- Accessibility Insights for Web: FastPass and Assessment, with installation instructions.
- WCAG 2.2 and Understanding WCAG 2.2 at the W3C: the standard and, for every criterion, the explanation.
- Using ARIA at the W3C: the rules for ARIA, including the first one.
- NVDA: the free screen reader for Windows.