Accessibility as an engineering requirement
Tuesday, 10:15: Sara, a blind guest, writes in. Her screen reader reads the hotel page fine, but the "Book" button is silent, so she can't finish a booking. An automated scan finds 41 issues in our 5-step booking flow.
Some of your users can't see the screen or use a mouse. Here's how they use your app, and why plain, correct HTML does most of the work for them.
- Problem
A blind guest using a screen reader, a guest with a tremor using only a keyboard, and a guest with low vision on a sunny terrace all try to book a room on your site.
- Why the simple system fails
A booking flow built from clickable divs, placeholder-only inputs, colour-only error messages and a custom date picker that traps focus is impossible for them to complete, and in many markets that's also a legal risk.
- Engineering concept
Expose meaning (role, name, state) through semantic HTML, make every action keyboard-operable with visible focus, ensure perceivable content (text alternatives, contrast, not colour alone), and use ARIA only to fill gaps.
- Technology
Semantic elements, labels and fieldsets, the accessibility tree, focus management, ARIA roles/states, WCAG AA criteria, axe/Lighthouse in CI, manual keyboard and screen-reader checks.
- Trade-offs
Custom widgets give design freedom but cost real engineering to make accessible; native elements are accessible by default but harder to style. Automated tests are cheap but catch only part of the issues; manual testing finds the rest.
The problem
2 min- Maya · Product managerSara uses a screen reader. She hears the hotel page fine, but "Book" says nothing, and she can't finish a booking.
- You · Engineer at StayHubOn screen it's a big blue Book button. What does her screen reader see?
- Lina · Senior engineer, your mentorLast episode of the module: you lead this one. I'll check.
The big idea
4 minMost accessibility is engineering: semantic HTML gives role, name and keyboard for free.
Use the element that means what you want, and the browser does the rest. A <button> fixes Sara's booking in one line.
How it works
5 min- 1DOM
Our HTML becomes the DOM, as in the first episode of this module.
- 2Accessibility tree
The browser builds the : for each element, a role (button, link), a name ("Book Deluxe Double") and a state (pressed, invalid).
- 3Screen reader
A speaks that tree: "Book Deluxe Double, button".
- 4Keyboard
Sara moves with Tab, jumps by headings, presses Enter or Space. Only focusable elements are reachable.
- 5Our div
No role, not focusable: Tab skips it, and at best it's read as plain text. Dead end.
So the work is engineering: 1. : <button> for actions, <a href> for navigation, <label> for inputs. 2. Keyboard: everything clickable works with Tab, Enter, Space, with a visible . 3. Perceivable: alt text, enough (our grey "from €140" is 2.8:1; AA asks 4.5:1), never colour alone. 4. sparingly: attributes that change what's announced, never behaviour.
Watch a decision being made
4 minYou lead the booking-flow fix, Lina checks
Sara can't book, and a scan finds 41 issues in the 5-step flow. Make it usable by keyboard and screen reader, and keep it that way.
- 1Scan, then unplug the mouse
axe: 41 issues, four patterns (32 clickable divs, 4 unlabelled inputs, 3 low contrast, 2 missing alt). Then book with keys only: step 3, the custom date grid, is a dead end.
Why: Lina's check: "Right. Scanners can't judge focus or dead ends; a real keyboard booking can."
- 2Fix the components, not the pages
Clickable divs become
<button>and<a href>in the shared card and button components; every input gets a visible<label>.Why: Lina's check: "One fixed component fixes every page that uses it."
- 3Rebuild the date picker on a native dialog
<dialog>withshowModal(), days as buttons with arrow keys, focus returned to the opener on close.tsxopener.current = document.activeElement as HTMLElement; dialog.showModal(); // focus moves in, page behind is inert, Esc closes // on close: opener.current?.focus(); // back to "Choose dates"Why: Lina's check: "The native dialog traps focus and handles Escape. You only add what's missing."
- 4Fix contrast, lock it in
Price text to at least 4.5:1, "Error:" next to red borders. axe in CI, tests that find buttons by role and name, "book by keyboard" in the definition of done. Then ask Sara to try again.
Why: Lina's check: "If a test can't find the button by role and name, neither can a screen reader."
In real life
3 minGOV.UK: native HTML first
- Publishes an open design system built on plain, semantic HTML
- Has publicly committed to accessibility standards across its services
- Design freedom is deliberately limited
Fits services everyone must use, with forms at their core.
Google Docs: custom rendering, a screen-reader mode
- Its editor draws much of the document itself
- Its help pages tell screen-reader users to turn on a dedicated setting
Fits a complex editor native elements can't express.
What goes wrong
3 minKeyboard users lose track of where they are.
Keep a visible style; use :focus-visible.
It disappears on typing and isn't reliably announced as the name.
A visible <label> tied to the input.
Automated tools catch only part of real issues, often cited as a third to a half.
A keyboard booking and a screen-reader check in the definition of done.
Trade-offs
3 min| Option | Fits when | You pay | Main risk |
|---|---|---|---|
| Native HTML | Almost always | Some controls hard to style | Little |
| Custom widget + ARIA | No native element fits | All keyboard, focus, announcements | Subtly broken for screen readers |
| Headless library | Complex widgets, several teams | A dependency, upgrades | Wrong labels in use |
- Maya · Product managerI want our own date picker with the price under each day. Our best feature for flexible guests.
- Karim · CTONative
<input type="date">is accessible for free and ships this week. Custom is weeks of work. - Maya · Product managerThe native one can't show prices. Guests book blind on cost.
Your job as tech lead
4 minClaude Code can write the code for this. You still own the decisions, and you have to know what good looks like to accept or reject its work.
- The standard you commit to (WCAG AA for the booking flow), and that a release can't ship if a keyboard user can't book.
- Who tests what: axe in CI as a safety net, a human keyboard run through all 5 steps every release, a regular screen-reader check.
- Which colours change: grey price text at 2.8:1 must reach 4.5:1, with design's sign-off.
Sara, a blind guest with a screen reader, couldn't book: "Book" is a clickable div read as plain text. axe finds 41 issues in our 5-step booking flow, and a keyboard-only test gets stuck at step 3, the date picker. Replace clickable divs with real buttons and links, name every icon button, label every field, and make the date picker keyboard-usable. Darken "from €140" to at least 4.5:1. Keep the steps. Plan first, add axe checks, and describe your keyboard check of each step.
- Book a room yourself with Tab, Enter, Space and arrows, always seeing where focus is.
- Claude lists each div it replaced and with what element. "Added role=button" is not a fix.
- One screen-reader pass: each button says what it does, like "Book Hotel Alfama, button".
role="button"on divs without focus or Enter and Space.- The focus outline removed because it "looks ugly".
- "Accessible" because axe reports zero, with no keyboard test.
Check yourself
2 min<div class="btn-primary" onClick={book}>Book now</div>. What's the best fix?Next episode: the mobile app's hotel screen makes 6 API calls in a row, and Maya wants "rooms left" to update live. Is our REST API still the right shape?
Mark it complete to update your skill map.
Go deeper (optional)You don't need this to understand the lesson. Open it when a project needs the details: Technical deep dive, Visual diagram, Analogy, Check your understanding, Practice ladder, Explain it yourself.
Technical deep dive
You lead the booking-flow fix, Lina checks
Sara can't book with a screen reader, and an automated scan finds 41 issues in the 5-step booking flow. Make the flow usable by keyboard and screen reader, and stop it from breaking again.
- 1Scan for the repeated patterns
Run axe on each step. 41 issues, but only four patterns: 32 clickable divs, 4 inputs without labels, 3 low-contrast texts, 2 images without alt.
Why: Grouping issues by pattern shows that a few component fixes clear most of the list.
- 2Unplug the mouse and book
Try to book with only Tab, Shift+Tab, Enter, Space, arrows and Escape. Steps 1–2 pass. Step 3, the custom date grid, is a dead end: focus can't enter it, and it escapes behind the modal.
Why: Scanners can't judge focus order or dead ends. A real booking by keyboard can.
- 3Fix the components, not the pages
Replace clickable divs with
<button>and<a href>in the shared card and button components; give every input a visible<label>.html<label for="email">Email</label> <input id="email" type="email" autocomplete="email" aria-invalid="true" aria-describedby="email-err"> <p id="email-err">Error: enter an email like name@example.com</p>Why: One fixed component fixes every page that uses it. Fixing pages one by one leaves the next page broken.
- 4Rebuild the date picker on a native dialog
Use
<dialog>withshowModal(), days as<button>s with arrow-key movement, and return focus to the opener on close.tsxopener.current = document.activeElement as HTMLElement; dialog.showModal(); // focus moves in, page behind is inert, Esc closes // on close: opener.current?.focus(); // back to "Choose dates"Why: The native dialog handles focus trapping and Escape. We only add what's missing: arrow keys and giving focus back.
- 5Fix what colour alone says
Darken the price text to reach at least 4.5:1, and add the word "Error:" to messages that were only a red border.
Why: Low-vision and colour-blind guests miss grey-on-white and red-only signals.
- 6Lock it in
Add axe to component tests and CI, query elements by role and name in tests, and add "book by keyboard" to the definition of done. Then ask Sara to try again.
tsexpect(screen.getByRole("button", { name: /book deluxe double/i })).toBeEnabled(); expect(await axe(container)).toHaveNoViolations();Why: If a test can't find the button by role and name, neither can a screen reader. CI stops regressions; a real user confirms the fix.
| Need | Use first (native) | ARIA only if… |
|---|---|---|
| Clickable action | <button> | Never needed for a plain action |
| Navigation | <a href> | Never needed for a plain link |
| Labelled input | <label for> + <input> | aria-label when no visible label is possible (icon-only) |
| Live status ("Room held for 10 min") | Visible text | aria-live="polite" so updates are announced |
| Tabs, combobox | No full native equivalent | Follow the ARIA Authoring Practices pattern, including keyboard |
Visual diagram
From your markup to what assistive technology announces.
Analogy
- In a buildingStandard signs for lifts and exitsOn a web pageSemantic HTML
- In a buildingA door painted to look like a wallOn a web pageA clickable div
- In a buildingThe floor plan read aloud at receptionOn a web pageThe accessibility tree
- In a buildingAn "Exit" sticker on a cupboard doorOn a web pageWrong ARIA
- In a buildingA ramp next to the stairsOn a web pageKeyboard support
Where the analogy breaks: a building is inspected once. A web page changes every week, so accessibility checks belong in your automatic tests, plus regular hands-on keyboard and screen-reader checks.
Check your understanding
Practice ladder
Tab through a room card
Scenario Warm-upA guest using only the keyboard presses Tab repeatedly through this room card. Focus arrives from the element just before it.
<div class="card" onclick="openRoom(42)"> <img src="room.avif"> <h3>Deluxe Double</h3> <span class="price" style="color:#999">from €140</span> <div class="heart" onclick="fav(42)">♥</div> <a href="/rooms/42/photos">See photos</a> <button type="button">Book</button> </div>
List, in order, which elements receive focus, what a screen reader announces for each, and which actions the guest can't reach at all.
Finish the accessible booking field
Code PracticeHere's the guest-name field at checkout, after a first attempt. Fill in the three gaps marked ___ so that the field has a real name, the error is announced and linked to the field, and the error isn't conveyed by colour alone.
___ <!-- 1: the field's name --> <input id="guest-name" type="text" autocomplete="name" aria-invalid="true" ___> <!-- 2: link the error --> <p id="name-err" class="error">___</p> <!-- 3: the error text -->
The room-held countdown
Debugging PracticeAt checkout, StayHub holds the room for 10 minutes and shows a countdown at the top: "Room held for 9:59". At 0:00 the hold is released and a red banner appears: "Room released".
A keyboard-only guest reports two problems: 1. Her screen reader never told her the hold was about to end, so she lost the room. 2. When the "Room released" dialog opened, focus stayed on the form behind it and she kept typing into fields she couldn't see.
Propose fixes for both, and say how you'd test them.
Accessibility check: before release or after?
Decision StretchKarim: "Six engineers, lots to ship. Do we add accessibility checks to every pull request, or do a full audit twice a year?"
The facts: - The last audit found 41 issues; fixing them took about 2 weeks. - 80% of issues came from 5 shared components (button, card, input, dialog, date picker). - Adding axe to component tests and CI takes about 2 days; a keyboard check per feature adds about 15 minutes. - An external audit costs about two weeks of one engineer's time to support and fix.
Write a short decision: "We chose X over Y because…, accepting the risk of…".
Explain it yourself
A teammate says accessibility is a design concern and can be handled after launch. Explain why it's an engineering requirement and what you'd put in place.