- img"Nick Daniel"
- heading"Nick Daniel"[level=1]
- text: Richmond, VA
- link"endash.us": the space between
- text: long-time a11y talker and tinkerer
- button"Err while live-coding"[pressed]
Why this talk: the machines started using our UIs. Tree or screenshot, they stumble in the same places screen reader users do.
Accessible design has always been good design.
Five habits. Good for people. Now good for robots too.
Part 1StructureLandmarks, headings, a title.
Part 2ControlsReal elements, real names.
Part 3InteractionsState lives in the tree.
Part 4ErrorsWhich field. What. How.
Part 5RecoverySay what changed. Offer a way back.
Who's reading your UI now
The tree in 40 seconds
Role
What is it? button, heading, dialog, textbox. From the element, or from role=.
Name
What is it called? From its text, its <label>, or an ARIA attribute. Placeholder is not a name.
<button aria-expanded="false" aria-controls="filters">Filters</button>
<!-- becomes, for every reader on the previous slide: -->
button "Filters" [expanded=false]
<!-- and relationships: describedby, labelledby, heading levels -->
Where we still are
94.8% of the top million home pages had detectable WCAG failures. Average: 51 per page. The top six are all tree bugs.
Low-contrast text79.1%Missing alt text55.5%Missing form labels48.2%Empty links45.4%Empty buttons29.6%Missing document language15.8%
Source: WebAIM Million 2025, webaim.org/projects/million. Share of home pages with at least one instance.
Part 1 – of 5
Clear structure
Landmarks, headings, a title, a language. The map every reader builds before touching anything.
A class is a rumor. An attribute is a fact. <dialog> does the rest for free.
Part 4 – of 5
Useful errors
Which field. What's wrong. How to fix it. Attached to the field, not floating near it.
An error the robot can read
Task: "Place the order." The email is already wrong.
An error answers three questions
Color and vibes
<input class="err" placeholder="Email">
…
showToast("Something went wrong")
Which · what · how
<label for="em">Email</label>
<input id="em" type="email"
aria-invalid="true"
aria-describedby="em-err">
<p id="em-err">Enter an email with @,
like nick@endash.us</p>
Then move focus to the first invalid field, and put a summary in a role="alert". Same words on screen and in the tree.
Part 5 – of 5
Paths to recovery
Say what changed. Offer a way back.
Did that work?
Task: "Remove the croissant. Then put it back."
Announce the change. Forgive the click.
One polite announcer
<p id="status" role="status"></p>
status.textContent =
"Croissant removed.";
status.append(undo); // a way back
After the big one
<h2 id="done" tabindex="-1">
Order placed</h2>
<p>Ready in 5 minutes.</p>
<button>Back to menu</button>
done.focus(); // then hand over
Agents wait for an announcement the same way screen reader users do. Silence reads as failure to both.
The whole order, without looking
Task: "Order an Oat Latte and a Croissant. Receipt to nick@endash.us."
Same commit. Two users.
Three, if you count your test suite.
And then –
Make the robots work for you
The same machine that stalls on your UI is the cheapest accessibility engineer you'll ever hire. Give it three things.
1 · Give your agent the rules
# .claude/skills/a11y-first/SKILL.md (or AGENTS.md, CLAUDE.md, .cursorrules)
1. Use the element that means it: button, a[href], label, dialog, h1–h6, nav.
2. Name every control with visible text. Icon only? Hidden text. aria-label last.
3. State is an attribute, never a class: disabled, aria-current, aria-invalid.
4. Errors: aria-invalid + aria-describedby, focus the first bad field, role=alert.
5. Off-screen changes go through one role="status" region. Big moves move focus.
Before you say "done": run `npm test -- a11y` and paste the aria snapshot.
One file in the repo. Every agent that touches your UI reads it first. The five habits, as a lint rule the robot applies to itself.
2 · Ask it the screen reader question
“Read this screen the way a screen reader would. List every control by role and name. Flag anything unnamed, any state that lives only in a class, and any change that isn't announced. Then give me the smallest diff.”
Paste it with a component, a PR, or a staging URL. Claude Code, Cursor, Copilot, a chat window. It's the agent log from the demos, on demand.
3 · Let the tests do the checking
test.beforeEach(({ page }) => page.goto("/"));
test("the menu, as the robot reads it", async ({ page }) => {
await expect(page.getByRole("main")).toMatchAriaSnapshot(`
- heading "Fuel for the space between" [level=1]
- list:
- listitem:
- button /Add Oat Latte to cart/
`);
});
test("no axe violations", async ({ page }) => {
expect((await new AxeBuilder({ page }).analyze()).violations).toEqual([]);
});
Snapshot for names and structure, @axe-core/playwright for the WebAIM six, a Tab walk for reach. Green means a person and a robot can both use it.
4 · Point an agent at staging
agent task: check out
found: button "Checkout" → clicking
something appeared, but the tree shows no dialog. Focus never moved.
trying Escape… still there. Esc means nothing here.
✗ task technically done. I never knew I was in a modal.
A keyboard user would have tabbed into the page behind it.
Playwright MCP or any browser agent, plus the prompt, on a Friday. Every stall is a bug report written in plain English, filed by your cheapest tester, and the fix is a commit that also helps a person.
This week
Drop the skill file into your repo. Ten minutes.
Run the prompt on your worst screen. Read the answer like a bug report.
Add the three tests: aria snapshot, axe, Tab walk.
Point an agent at staging. Where it stalls, a person stalls.
Build it for the person. The robot comes free.
Before you go – three quick asks
Say hi
Questions after the session, or tell me what you're making accessible.
nick@endash.us
Find me
Writing and tools, including everything from this talk.
endash.us
Take it with you
This deck, the café, the skill file and the tests.