Accessibility & LLMs
LLMs are a brilliant tool for auditing code against WCAG guidelines. They can scan through templates, components and JavaScript and catch issues that are easy for a human to miss — missing alt text, incorrect ARIA roles, colour-dependent states and so on. Unfortunately, they'll also routinely break accessibility best practices unless you steer them carefully.
This post covers where LLMs genuinely help with accessibility, where they fail in ways you need to review, and a skill you can use to delegate the tedious checklist work so you can focus your attention on the harder problems.
The anchor-wrap trap
I've seen this multiple times and if you're not reviewing your code properly, you'll miss it. You ask an LLM to build a card that links to a page, and it wraps the entire card in an <a> tag:
<a href="/post/some-post" class="card">
<img src="thumb.jpg" alt="Photo of a cat wearing a tiny hat" />
<h2>Some Post Title</h2>
<p class="meta">By someone • 5 min read</p>
</a>Screen readers will announce every piece of content inside that anchor. The user has to sit through the full alt text, the title, the meta, everything, before they can decide whether they want to follow the link; not good!
The correct pattern here is the box-link method: a card whose entire area is clickable using an absolutely positioned anchor with a pseudo-element, or a single anchor with aria-label when the post title sits elsewhere in the card.
<article class="card">
<img src="thumb.jpg" alt="" />
<h2>Some Post Title</h2>
<p class="meta">By someone • 5 min read</p>
<a href="/post/some-post" class="card-link" aria-label="Some Post Title"></a>
</article>.card {
position: relative;
}
.card-link::after {
content: '';
position: absolute;
inset: 0;
}The screen reader announces only the link text (or the aria-label), and the user can navigate by headings or links without being overloaded with information.
Where LLMs actually shine
The anchor-wrap problem is an example of an LLM defaulting to what looks correct syntactically without understanding the user experience implications (things like Google PSI will similarly fail to point out issues like this). The same thing happens with focus management, dynamic content announcements, and heading hierarchies — an LLM might write valid HTML that's technically accessible while still producing a poor experience.
Where LLMs genuinely excel is filling in the gaps, for example:
- Suggesting missing
roleattributes on custom interactive elements. - Flagging images without alt text and proposing descriptive alternatives.
- Recommending semantic HTML in places where a developer used generic
<div>elements. - Identifying colour-only information conveyance and suggesting additional indicators.
- Reviewing form markup for missing label associations.
What these all have in common is that they're discrete, verifiable checks. An LLM can reliably determine whether a given element has an alt attribute or whether a form input has an associated <label>. The anchor-wrap problem sits in a different category — it's a design-pattern decision that depends on content structure, screen reader flow, and user intent. That's not something you can encode as a pass/fail rule, and it's not something I'd trust an LLM to judge consistently.
The skill below is designed for the first category: concrete, testable WCAG violations. It won't catch everything. What it does is handle the tedious checklist work so you can focus your attention on the harder problems that need human judgment.
The a11y skill
I've been using a Claude skill that formalises this into a repeatable audit workflow. The idea is that you feed it a template, component or run it bare and it works through a structured checklist rather than making guesses.
Here's the current version:
---
name: a11y
description: Run a WCAG AA accessibility audit on templates and components
allowed-tools: [Read, Glob, Grep, Task]
---
# Accessibility Audit (WCAG 2.1 AA)
Run a thorough accessibility audit against WCAG 2.1 Level AA guidelines on the current project's templates, components, and JavaScript.
## Scope
Audit all template files (Twig, HTML) and interactive JS components in the working directory. Focus on:
### 1. Keyboard Navigation
- All interactive elements (links, buttons, inputs, custom widgets) must be reachable and operable via keyboard alone
- Check for proper `tabindex` usage (avoid positive values, ensure custom interactive elements are focusable)
- Verify visible focus indicators exist (no `outline: none` without a replacement)
- Check that modal/lightbox components trap focus when open and restore focus on close
- Verify carousels and sliders are keyboard-operable (arrow keys, escape to close)
### 2. ARIA Roles & Attributes
- Interactive components must have appropriate `role` attributes when semantic HTML isn't used
- Check for `aria-label`, `aria-labelledby`, `aria-describedby` on elements that need accessible names
- Verify `aria-expanded`, `aria-hidden`, `aria-modal`, `aria-live` are used correctly
- Ensure decorative images have `aria-hidden="true"` or empty `alt=""`
- Check that landmark roles are present (`main`, `nav`, `banner`, `contentinfo`)
### 3. Semantic HTML
- Heading hierarchy must be logical (no skipped levels within a page context)
- Lists should use proper `ul`/`ol`/`li` markup
- Forms must have associated `label` elements or `aria-label`
- Buttons used for actions must be `<button>`, not `<a>` or `<div>`
- Navigation regions should use `<nav>` with distinct `aria-label` when multiple exist
### 4. Images & Media
- All informational images must have descriptive `alt` text
- Decorative images must have `alt=""` or `aria-hidden="true"`
- Video embeds should reference captions/transcripts where possible
- SVG icons used as interactive controls need accessible names
### 5. Colour & Contrast
- Flag any inline text colour classes that may fail 4.5:1 contrast against their background
- Check for information conveyed by colour alone (e.g. error states relying solely on red)
- Review focus indicator visibility against backgrounds
### 6. Dynamic Content
- Modals/lightboxes: check for `role="dialog"`, `aria-modal="true"`, focus trapping, escape key to close
- Content that changes dynamically should use `aria-live` regions where appropriate
- Alpine.js `x-show`/`x-if` toggled content should not leave orphaned ARIA states
## Output Format
For each issue found, report:
- **File**: path and line number
- **Severity**: Critical / Major / Minor
- **WCAG Criterion**: e.g. 2.1.1 Keyboard, 1.1.1 Non-text Content
- **Issue**: Brief description
- **Fix**: Recommended remediation
Group findings by file. At the end, provide a summary count of issues by severity and an overall pass/fail assessment for WCAG 2.1 AA.Install the skill
I've created a gist with the above skill on my github and a prompt to install it*:
Install this Agent Skill:
https://gist.githubusercontent.com/wuhhh/3080cc3aee496441d7e5d3b850d76796/raw/a11y-skill.md
Fetch that raw URL — not the gist web page, which mangles the YAML frontmatter.
Before writing anything, ask me two things and wait for my answers:
a) Global or project-local? Global makes it available in every project; project-local puts it
in the repo so it can be committed and shared.
b) Should the skill auto-detect my stack (the default, works anywhere), or would I like it
pinned to the specific template languages and component formats this project uses?
RULES:
1. This is a create-only task. Do not delete, overwrite, move, rename, or merge any existing
file. The only write you may make is creating one new skill directory and one new SKILL.md.
2. A skill named `a11y` may already exist. Check every skills location your harness reads, not
just the one you plan to write to — several harnesses read more than one (opencode and
Gemini CLI both also read `~/.claude/skills/` and `~/.agents/skills/`).
If `a11y` already exists anywhere, STOP. Report the path and ask me how to proceed.
Do not assume I want it replaced.
3. Install to the correct directory for this harness and the scope I chose:
global project-local
Claude Code ~/.claude/skills/a11y/SKILL.md .claude/skills/a11y/SKILL.md
opencode ~/.config/opencode/skills/a11y/… .opencode/skills/a11y/SKILL.md
Codex ~/.codex/skills/a11y/SKILL.md .codex/skills/a11y/SKILL.md
Gemini CLI ~/.gemini/skills/a11y/SKILL.md .gemini/skills/a11y/SKILL.md
If your harness isn't listed, search the web for its skill locations before writing.
Tell me which path you chose and why.
4. The gist file is called `a11y-skill.md`. It must be saved as `SKILL.md` inside a directory
named `a11y`. Keep the YAML frontmatter byte-identical except for the `allowed-tools` line.
5. `allowed-tools` uses Claude Code tool names. Map them to this harness's equivalents, or drop
any that have no equivalent. Do not invent tool names. Tell me what you changed.
When done, read the file back and confirm: the `---` delimiters are intact, nothing precedes the
opening `---`, and `name` and `description` are both present. A malformed SKILL.md is skipped
silently by some harnesses. Then tell me how to invoke the skill in this harness.*Hopefully it goes without saying that you should never blindly copy and paste prompts into your terminal without reading them first!
What the skill actually catches
A few real-world examples of things the skill has flagged in audits:
Keyboard navigation. The most common issue is a custom modal or dropdown that doesn't trap focus. A user tabs past the close button and ends up somewhere random behind the overlay. The fix is usually a focus-trap utility that cycles through the dialog's focusable elements and intercepts Tab/Shift+Tab. Escape-to-close is another one that gets missed surprisingly often.
Dynamic content. Toggling visibility with (e.g. with Alpine's x-show or Vue's v-if) doesn't automatically announce the change to screen readers. If content appears or disappears in response to a user action, it needs an aria-live region or the toggled element needs a focus update. Alpine's x-effect makes this fairly straightforward to wire up, but it's rarely the default behaviour. The skill checks for orphaned ARIA states too — a panel that's hidden but still carries aria-expanded="true" on its toggle.
Focus indicators. Design systems love to reset outline: none on focus and forget to provide a replacement. The skill scans for this pattern and flags it. Visible focus indicators are WCAG 2.4.7 and they're one of the easiest fixes to make, yet they're consistently one of the most common issues in real-world audits.
Final thoughts
LLMs are a genuinely useful tool for accessibility auditing, but they need guardrails. Without a structured prompt they'll write code that looks correct but fails in practice — the anchor-wrapped card being a textbook example.
A well-written skill prompt flips the dynamic. Instead of the LLM guessing what to check, you give it a checklist. Instead of it writing accessibility theatre, you get actionable issues with WCAG criteria, severity ratings, and specific remediation steps. The output is consistent enough to feed straight into a ticket system or a review session.
If you're using LLMs to audit your templates, I'd recommend formalising your process into something similar. The prompt doesn't need to be as long as the one above — even a short checklist will get you better results than asking "is this accessible?" and hoping for the best.