| name | a11y-review |
| description | Static analysis pass over JSX/HTML/CSS source for WCAG 2.2 AA failure patterns. Does NOT run axe-core, does NOT measure real contrast, does NOT test runtime behavior — pair with a11y-testing for that. Use when the user asks for an accessibility review, a11y check, WCAG review, "is this accessible?", "does my site meet WCAG?", "check my component for accessibility", "review accessibility of", "check for a11y issues", "fix accessibility issues", or any request to scan a web page, component, or codebase for WCAG 2.2 AA failure patterns. Findings are tagged by detection confidence; the report does not produce a Pass/Fail verdict because static analysis cannot defensibly produce one.
|
| argument-hint | [path/to/component, directory, or 'all' for full codebase scan] |
| allowed-tools | Read, Glob, Grep, Bash |
Accessibility Review (static analysis)
Perform a structured static analysis pass over the specified component, page, or codebase. Execute all eight phases in order and produce an actionable, severity-ranked report with each finding tagged by confidence level.
What this skill is — and what it isn't
This skill performs pattern-based static analysis over .tsx / .jsx / .astro / .html / .css source. It is not a WCAG audit, does not run axe-core, does not test runtime focus behavior, and does not test with screen readers. It does compute WCAG sRGB contrast ratios for Tailwind class pairs co-occurring on the same element (the check-tailwind-contrast.py validator), but it cannot see DOM-inherited backgrounds without runtime.
What this skill catches well (high confidence):
- Missing
alt on <img>, missing <label> association, missing accessible name on icon buttons
outline: none / outline-none without a focus-visible replacement (Tailwind users — axe often misses this because it inspects computed styles)
aria-hidden="true" on focusable elements or their ancestors
- Hardcoded ARIA states (
aria-expanded="true" as a string literal, not driven by component state)
<div onClick> / <span onClick> without role + keyboard handlers
- Skipped heading levels, missing landmarks
- React-specific focus drift (modal close handlers without focus restoration)
- Color contrast for Tailwind class pairs on the same element —
check-tailwind-contrast.py computes real WCAG 2.x sRGB ratios from the Tailwind v3 default palette plus custom colors parsed from tailwind.config.{js,ts,cjs,mjs}. Flags AA failures (4.5:1 body / 3:1 large or UI) and surfaces the actual computed ratio. Function-based palettes (colors imported from another file) are skipped with a warning.
What this skill flags but cannot confirm (medium confidence):
- Color contrast where the background comes from a DOM ancestor — the validator can't resolve inherited backgrounds across the rendered tree without a browser
- Color contrast involving alpha-blended (translucent) backgrounds — not handled by the validator
- ARIA state synchronization beyond the simplest cases
- Whether a
<fieldset> correctly wraps a logical group
What this skill cannot see at all (requires runtime / manual):
- Whether
alt text is meaningful — only checks presence and obvious anti-patterns
- Reading order matches visual order — needs DOM traversal at runtime
- Focus indicator is sufficiently visible — needs a browser
- Live region announcements arrive at the right time — needs real assistive tech
- Keyboard interactions feel logical to a user — needs a human
- Custom widget keyboard contracts implemented end-to-end — needs APG-pattern verification with a screen reader
- Screen reader announcement coherence — needs NVDA / JAWS / VoiceOver
For runtime verification, use a11y-testing to set up real axe via jest-axe and @axe-core/playwright. For manual verification, use a real screen reader.
Audit Scope
When invoked with a path, scan that file or directory. When invoked with "all" or no argument, locate the main source directory (src/, app/, components/, pages/) and scan the full codebase.
Exclude: node_modules, dist, build, .next, coverage, .astro.
Cross-model note
Works identically across Opus-, Sonnet-, and Haiku-class models. The validators in Phase 0 are deterministic Python — model choice doesn't affect their output. Output schema is identical regardless of model.
Execution differences by model:
- Opus-class / Sonnet-class — issue Phase 0 + Phase 1 reads + stack detection as a single parallel tool-call burst (see "Execution: parallel-first" below). The four validators run concurrently in one Bash call.
- Haiku-class — may follow phase order sequentially if parallel batching misbehaves. The four validators still run in parallel via the
& + wait shell pattern; only the LLM-side reads fall back to sequential.
If any model loses tool-call coherence on the parallel batch, fall back to running Phase 0 first, then Phase 1 — final report is identical, only wall time changes.
Execution: parallel-first
Phases 0 and 1 have no inter-phase dependencies — Phase 0 scripts scan files independently of Phase 1's file map, and Phase 1's file map is consumed only by Phases 2–8. Issue them as a single parallel batch:
Batch to issue at the start of the skill:
- Bash (one command, scripts run concurrently) — kick off all four validators with
& and wait. They write JSON to /tmp/rad-a11y-*.json.
- Glob in parallel — discover
**/*.tsx, **/*.jsx, **/*.astro, **/*.html, **/*.css, **/*.scss, **/tailwind.config.*.
- Read in parallel —
package.json (stack detection), tailwind.config.{js,ts,cjs,mjs} if present, the four validator JSON outputs (after the Bash batch completes).
Phase 1's file count + stack detection is a synthesis over the Glob and Read results; it doesn't gate the validators or the LLM phases that follow.
Skip the parallel batch and run sequentially only if the model loses tool-call coherence — final report is identical regardless.
Phase 0: Run validators (deterministic Python scripts)
New in 2.1. Before any LLM regex work, run all four rad-a11y validators in parallel. They emit JSON; their output populates the [STATIC] (and some [HEURISTIC]) findings deterministically. The LLM phases below then handle only what scripts can't decide — alt-text meaningfulness, complex ARIA logic, reading order, semantic intent — and tag those findings [HEURISTIC] or [NEEDS-MANUAL].
Skip Phase 0 silently if Python is unavailable (python3 --version and python --version both fail). In that case, Phases 2–8 below run as the original LLM regex passes, with all findings tagged [HEURISTIC] since the deterministic backstop is missing. Note this in the report header: ⚠ Python unavailable — running in fallback mode; static patterns are heuristic.
Execute as a single parallel Bash batch (one shell spawn for all four):
PY=$(command -v python3 || command -v python)
PR="${plugin_root}/scripts"
$PY "$PR/scan-jsx-patterns.py" "$PWD" > /tmp/rad-a11y-jsx.json &
$PY "$PR/check-tailwind-contrast.py" "$PWD" > /tmp/rad-a11y-contrast.json &
$PY "$PR/check-target-size.py" "$PWD" > /tmp/rad-a11y-target.json &
$PY "$PR/lint-aria.py" "$PWD" > /tmp/rad-a11y-aria.json &
wait
Then read all four JSON outputs in a parallel Read batch. Each emits the schema documented in scripts/README.md:
{
"tool": "...",
"files_scanned": 42,
"findings_count": 7,
"findings": [ { "category": "...", "wcag": "...", "severity": "...", "confidence": "STATIC", "file": "...", "line": 12, "snippet": "...", "fix": "..." } ]
}
Use the validator findings verbatim — do not re-derive, paraphrase, or second-guess the snippet/line/category fields. They are deterministic; rewriting them adds drift. The LLM's job in Phases 2–8 is to add findings the scripts couldn't make, not to second-guess the ones they did.
If lint-aria.py reports "plugin_installed": false, surface its recommendation field once at the top of the report so the user knows to install eslint-plugin-jsx-a11y for higher-coverage linting.
Phases 2–8: LLM-only patterns (everything scripts can't decide)
The phases below now run after Phase 0. They cover only what static scripts cannot determine — patterns requiring contextual judgment, cross-element analysis, or semantic intent. Do not duplicate work the scripts already did; if scan-jsx-patterns flagged a missing alt, do not re-flag it from the LLM pass. Instead, layer LLM judgment on:
- Alt-text meaningfulness — scripts catch missing/bad-pattern alt; LLM judges whether present alt is informative. Tag
[HEURISTIC].
- ARIA logic in complex widgets — scripts catch hardcoded literals; LLM judges whether ARIA states for tabs/menus/comboboxes follow the APG keyboard contract end-to-end. Tag
[HEURISTIC].
- Reading order vs. visual order — DOM-runtime concern; tag
[NEEDS-MANUAL].
- Live region timing — runtime concern; tag
[NEEDS-MANUAL].
- Empty / icon-only buttons — cross-element analysis (is there visible text in a sibling? a
sr-only span?); tag [HEURISTIC].
- Placeholder-as-label — cross-element (does a
<label htmlFor> exist nearby?); tag [HEURISTIC].
- Fieldset grouping correctness — is the group of related inputs actually wrapped?
[HEURISTIC].
Phases 1 (file map) and 2–8 below remain as written for the LLM-judgment slices. Phase 0 supersedes the deterministic-pattern slices that used to live inside Phases 2–8.
Phase 1: Orient — Build the File Map (and detect stack)
In parallel with Phase 0 (see "Execution: parallel-first" above):
-
Use Glob to discover all UI files:
**/*.tsx, **/*.jsx, **/*.astro — components and pages
**/*.html — static markup
**/*.css, **/*.scss, **/*.module.css — stylesheets
**/tailwind.config.* — Tailwind configuration
-
Read package.json (if present) to detect the stack. Build a stack-detection record used by Phases 4 and 8 to skip irrelevant slices:
| Signal | Detected stack |
|---|
react in dependencies | react: true |
next in dependencies | react: true, nextjs: true |
astro in dependencies | astro: true |
tailwindcss in dependencies OR tailwind.config.* exists | tailwind: true |
@radix-ui/* in dependencies | radix: true |
@headlessui/* in dependencies | headlessui: true |
None of the above + .html files exist | plain_html: true |
-
Count total component files. Report: "Scanning X components across Y directories. Detected stack: [list]."
Stack-aware phase routing
Use the detection record to decide which downstream phases run:
- Phase 4 (color/contrast/motion) — runs always, but the contrast slice notes whether
check-tailwind-contrast.py actually had Tailwind classes to evaluate. If tailwind: false AND no inline text-* / bg-* classes were found in source, the contrast phase reports "No Tailwind class pairs detected — runtime contrast verification required (use axe DevTools)" instead of running the LLM-judgment slice.
- Phase 8 (stack-specific) — only execute the slices matching detected stack:
- Tailwind slice — runs if
tailwind: true
- React slice — runs if
react: true
- Astro slice — runs if
astro: true
- Radix / Headless UI slice — runs if
radix: true or headlessui: true
- Plain HTML — only the framework-agnostic checks from Phases 2–7 apply; Phase 8 is skipped entirely with one line: "Phase 8 (stack-specific): no framework detected; framework-agnostic phases already covered."
Phases 2, 3, 5, 6, 7 (semantic, ARIA, contrast/motion, forms, SVG) are framework-agnostic and run regardless of stack.
Phase 2: Semantic Structure
Check every component file for semantic HTML violations.
Heading Hierarchy (WCAG 1.3.1)
- Flag any heading that skips a level (e.g.,
<h1> → <h3> with no <h2>)
- Flag multiple
<h1> elements on a single page
- Flag headings used purely for visual sizing (e.g.,
<h4> inside a card that is font-bold text-sm)
- Flag missing
<h1> on page-level components
Landmark Regions (WCAG 1.3.6)
- Flag pages missing
<main>, <header>, <nav>, or <footer> landmarks
- Flag multiple
<nav> or <header> elements that lack unique aria-label or aria-labelledby
- Flag
<div id="content"> patterns that should be <main>
Semantic Element Usage (WCAG 4.1.2)
- Flag
<div onClick> and <span onClick> without role and tabindex
- Flag
<div role="button"> when a <button> should be used
- Flag
<a> tags without href used as buttons
- Flag
<b> / <i> used for semantic emphasis (should be <strong> / <em>)
- Flag missing
<label> wrapping for icon-only inputs
- React: Flag wrapper
<div> inside <ul>, <ol>, <table> — suggest Fragments
Images and Alt Text (WCAG 1.1.1)
- Flag
<img> without alt attribute
- Flag
<img alt="image">, <img alt="photo">, or file-name-as-alt anti-patterns
- Flag
<img> with redundant alt (same as adjacent caption text)
- Flag decorative images without
alt="" or role="presentation"
Phase 3: ARIA Usage
Check all ARIA attribute usage across components.
The ARIA First Principles
- No ARIA is better than bad ARIA. Flag every instance where ARIA overrides correct native semantics.
- Flag
role="button" on <button> — redundant
- Flag
role="heading" on <h1>–<h6> — redundant
- Flag
role="list" on <ul> / <ol> — only needed in some browsers with Tailwind list-none
aria-hidden Misuse (WCAG 1.3.1, 4.1.2)
- Critical: Flag
aria-hidden="true" on any element that is focusable or contains focusable children
- Flag interactive buttons/links inside an
aria-hidden ancestor
- Flag SVG icons missing
aria-hidden="true" when decorative (or inside a labeled button)
Missing Accessible Names (WCAG 4.1.2, 2.4.6)
- Flag
<button> with no text content, no aria-label, and no aria-labelledby
- Flag icon-only buttons:
<button><svg>...</svg></button> with no accessible name
- Flag
<a> with no text, no aria-label, no aria-labelledby
- Flag
<input type="image"> without alt
ARIA State Synchronization (WCAG 4.1.2)
- Flag
aria-expanded attributes hardcoded to "true" or "false" that are not driven by state
- Flag disclosure components (accordions, dropdowns) missing
aria-expanded entirely
- Flag
aria-checked, aria-selected, aria-pressed not connected to component state
- React: Flag string literals
aria-expanded="true" — should be aria-expanded={isOpen}
Live Regions (WCAG 4.1.3)
- Flag dynamic status updates (toasts, alerts, loading states) with no
aria-live, role="status", or role="alert"
- Flag
role="alert" used for non-urgent messages (should be role="status")
- Flag live regions whose content is set before being added to the DOM (screen readers miss the announcement)
Phase 4: Keyboard and Focus
Check keyboard operability across all interactive components.
Focus Visibility (WCAG 2.4.7, 2.4.11)
- Critical: Flag any CSS with
outline: none, outline: 0, or Tailwind outline-none that is not immediately followed by a custom focus indicator
- Flag Tailwind
focus:outline-none without focus-visible:ring-* replacement
- Flag
*:focus { outline: none } global CSS reset without a replacement
Focus Order (WCAG 2.4.3)
- Flag
tabindex values greater than 0 (breaks natural DOM order)
- Flag visually ordered content whose DOM order is inverted via CSS
order, float, or position: absolute
Keyboard Traps (WCAG 2.1.2)
- Flag custom dropdown, popover, or menu components with no
Escape key handler
- Flag modal/dialog implementations that do not trap focus inside when open
- Flag custom components that intercept
Tab without providing a clear exit
Skip Links (WCAG 2.4.1)
- Flag page-level layouts that have no "Skip to main content" link
- Flag skip links present but permanently hidden (not just visually hidden until focused)
Interactive Element Requirements (WCAG 2.1.1)
- Flag
<div role="button"> / <div tabindex="0"> missing keydown/keyup handlers for Enter and Space
- Flag custom checkbox / radio implementations missing keyboard activation
React-Specific Focus Drift
- Flag
useEffect that removes a modal/panel from the DOM without calling .focus() on the trigger element
- Flag dialog close handlers that do not restore focus: look for
setIsOpen(false) or onClose() without a preceding triggerRef.current?.focus()
Phase 5: Color Contrast and Motion
Contrast (WCAG 1.4.3, 1.4.11)
Check Tailwind color utility classes and CSS color values for likely contrast failures:
- Flag text on colored backgrounds where the combination is known-low-contrast (e.g.,
text-gray-400 bg-white, text-yellow-300 bg-white)
- Flag UI component borders/outlines that use light neutrals (
border-gray-200, border-gray-300) on white backgrounds — likely fail 3:1
- Flag placeholder text (
::placeholder) styled with low-contrast colors
- Note: Full contrast ratios require a browser tool — flag suspects, recommend using axe DevTools or Colour Contrast Analyser for verification
Color-Only Meaning (WCAG 1.4.1)
- Flag error states indicated only by red border/text with no icon, label, or pattern
- Flag status indicators (badges, dots) that rely solely on color with no text or shape alternative
- Flag link text that is only differentiated from body text by color (no underline)
Motion and Animation (WCAG 2.3.3)
- Flag CSS
animation or transition on non-UI state changes (page decorations, hero animations) without @media (prefers-reduced-motion: reduce) override
- Flag Tailwind animations (
animate-spin, animate-bounce, animate-pulse) applied to non-status elements without motion-reduce: or motion-safe: modifier
- Flag auto-playing videos or carousels with no pause/stop control
- Flag content that flashes or blinks (potential seizure trigger)
Target Size (WCAG 2.5.8 — new in 2.2)
- Flag interactive targets smaller than 24×24px — look for
w-4 h-4, p-0, text-xs buttons that are icon-only
- Flag tightly packed link lists with less than 24px spacing between targets
Phase 6: Forms
Check all form components.
Label Association (WCAG 1.3.1, 3.3.2)
- Critical: Flag
<input>, <select>, <textarea> without an associated <label> (via for/id or wrapping)
- Flag
htmlFor in React that does not match an id on any input
- Flag
placeholder used as the only labeling mechanism
Required and Validation (WCAG 3.3.1, 3.3.2)
- Flag required fields with no
required attribute or aria-required="true"
- Flag required indicators (asterisks) with no explanation in the form instructions
- Flag error messages that are not programmatically linked to their input via
aria-describedby
- Flag
aria-invalid not set to "true" on invalid inputs during validation
Fieldset/Legend (WCAG 1.3.1)
- Flag groups of related checkboxes or radio buttons not wrapped in
<fieldset> + <legend>
- Flag
<legend> used but empty or containing only whitespace
Phase 7: SVG and Icons
Check all SVG usage.
Decorative SVGs (WCAG 1.1.1)
- Flag inline
<svg> without aria-hidden="true" when inside a labeled button or link (screen readers will attempt to read raw SVG content)
- Flag
<svg> without focusable="false" in IE/Edge-compatible codebases
Informative SVGs (WCAG 1.1.1)
- Flag standalone SVGs (logos, charts, illustrations) without
role="img" and either aria-label or a hidden text alternative
- Flag SVGs with a
<title> but no aria-labelledby pointing to it (cross-browser reliability issue)
Phase 8: Stack-Specific Checks
Tailwind CSS
- Flag
outline-none without focus-visible:ring-* replacement (most common Tailwind a11y failure)
- Flag missing
sr-only on icon-only button text labels
- Confirm
motion-safe: / motion-reduce: usage on animated elements
- Check for
list-none on <ul> — some screen readers strip list semantics; recommend role="list" if semantics matter
React
- Confirm
aria-* props use JSX booleans correctly: aria-expanded={isOpen} not aria-expanded="true"
- Flag
key props on focusable list items that change order (forces DOM remount, loses focus position)
- Flag
dangerouslySetInnerHTML in components — requires manual a11y verification of injected content
Astro
- Flag islands using
client:visible or client:idle on keyboard-interactive components — risk of hydration dead zones
- Recommend pre-hydrated ARIA states via
data-* attributes for components that render with client:visible
- Check for skip link in the root layout (
src/layouts/*.astro)
Radix UI / Headless UI
- Check that
asChild usage spreads all props and forwards refs — missing ref breaks Radix focus management
- Confirm Radix
Dialog.Content has aria-labelledby pointing to Dialog.Title
Reporting Format
Group findings into four severity levels. Report only findings with evidence. Every finding must carry a detection-confidence tag in addition to its severity — readers should be able to see which findings the static scan can prove and which are pattern-based heuristics or hand-offs to manual verification.
Detection-confidence tags
[STATIC] — Deterministic detection: the pattern is present in source and the failure mode is unambiguous from source alone. Example: <img> with no alt, aria-hidden="true" on a <button>, outline-none with no focus-visible: replacement on the same element. These are PR-blockers when they're also Critical/Serious severity.
[HEURISTIC] — LLM judgment over patterns where source isn't dispositive. Example: "this aria-label looks redundant with adjacent visible text," "this <div role="button"> probably needs keyboard handlers." These should be reviewed before fixing — the model can be wrong.
[NEEDS-MANUAL] — The pattern suggests a problem, but only a browser, axe runtime, or screen reader can confirm. Example: contrast pair flagged by class name (no real ratio computed), live region timing, focus indicator visibility quality, alt-text meaningfulness. Surfaced so the reader knows what to verify next.
Severity levels (orthogonal to confidence)
Critical (blocks PR when [STATIC])
Issues that make the UI completely unusable for keyboard or screen reader users.
- Keyboard traps (no Escape, focus stuck) —
[HEURISTIC] from source; needs runtime confirmation
- Missing focus indicators (bare
outline-none with no replacement) — [STATIC]
aria-hidden="true" on focusable elements — [STATIC]
- Interactive
<div> with no keyboard handler — [STATIC]
Serious (fix before ship)
Issues that significantly impair assistive technology users.
- Missing form labels —
[STATIC] (presence) / [HEURISTIC] (correct association)
- Icon-only buttons with no accessible name —
[STATIC]
- Missing heading structure on pages —
[STATIC]
- Dynamic content with no live region announcement —
[STATIC] (missing markup) / [NEEDS-MANUAL] (timing)
Moderate (fix in next sprint)
Issues that degrade the experience for some users.
- Skipped heading levels —
[STATIC]
- Missing skip links —
[STATIC]
- Hardcoded ARIA states not driven by component state —
[STATIC]
- Decorative SVGs not hidden from screen readers —
[STATIC]
Minor (best practice)
Low-impact issues or recommendations.
- Missing
lang on <html> — [STATIC]
- Generic link text ("click here", "read more") —
[HEURISTIC]
tabindex > 0 values — [STATIC]
- Missing
aria-label on duplicate landmarks — [STATIC]
For each finding, output:
[SEVERITY] [CONFIDENCE] WCAG X.X.X — Short description
File: path/to/file.tsx:line
Code: <the problematic snippet>
Fix: Specific remediation
[Notes: <any caveat — e.g., "real contrast ratio not computed; verify with axe DevTools">]
End-of-report summary
Do not issue a Pass / Fail / Compliance verdict. Static analysis cannot produce a defensible WCAG 2.2 AA pass/fail — that requires runtime + manual + assistive-tech verification. Instead, end with:
Static scan summary
-------------------
[STATIC] findings: <N> Critical, <N> Serious, <N> Moderate, <N> Minor
[HEURISTIC] findings: <N> total — review before fixing, model can be wrong
[NEEDS-MANUAL] flags: <N> total — verify with axe DevTools / screen reader
Top 3 priority fixes (highest severity × highest confidence):
1. <file:line> — <description>
2. <file:line> — <description>
3. <file:line> — <description>
Recommended next steps:
- Set up real axe runtime (use the a11y-testing skill) — covers categories static scan can't
- Manual screen reader pass on <list any [NEEDS-MANUAL] flagged components>
- Browser-based contrast verification on <list any color-related [NEEDS-MANUAL] flags>
What real axe / Lighthouse catches that this skill doesn't, and vice versa
Real axe (running in a browser via jest-axe or @axe-core/playwright) catches a different subset than this static skill. They overlap heavily but each catches things the other misses.
Real axe catches but this static skill cannot:
- Computed contrast ratios (real sRGB → WCAG math against rendered colors)
- Duplicate
id after dynamic component rendering
- ARIA attribute validity against the resolved role
- Some keyboard accessibility paths that depend on runtime DOM
This static skill catches but real axe often misses:
- Tailwind
outline-none written without a focus-visible:ring-* replacement (axe sees the resolved :focus style, which may have a default UA outline depending on browser/version, leading to false negatives)
- Hardcoded ARIA state literals in JSX source (
aria-expanded="true" as a string instead of ={isOpen}) — axe sees the value at the moment of scan, missing the bug that the value never updates
- React modal close handlers that don't restore focus to the trigger (axe sees the focus state after close, but the underlying source pattern is invisible to it)
- Astro
client:visible / client:idle on keyboard-interactive components (hydration timing issues)
The honest framing: /a11y-review and real axe are complementary, not redundant. Run both. The widely-cited "axe catches 30–80%" range refers to real axe — /a11y-review covers a subset of that range plus some patterns axe misses, which is why every finding here is tagged with detection confidence so you know what you actually have.