From rad-a11y
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.
How this skill is triggered — by the user, by Claude, or both
Slash command
/rad-a11y:a11y-review [path/to/component, directory, or 'all' for full codebase scan][path/to/component, directory, or 'all' for full codebase scan]This skill is limited to the following tools:
The summary Claude sees in its skill listing — used to decide when to auto-load this skill
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.
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.
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):
alt on <img>, missing <label> association, missing accessible name on icon buttonsoutline: 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 ancestorsaria-expanded="true" as a string literal, not driven by component state)<div onClick> / <span onClick> without role + keyboard handlerscheck-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):
<fieldset> correctly wraps a logical groupWhat this skill cannot see at all (requires runtime / manual):
alt text is meaningful — only checks presence and obvious anti-patternsFor 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.
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.
Works identically across Opus 4.7, Sonnet 4.6, and Haiku 4.5. 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:
& + 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.
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:
& and wait. They write JSON to /tmp/rad-a11y-*.json.**/*.tsx, **/*.jsx, **/*.astro, **/*.html, **/*.css, **/*.scss, **/tailwind.config.*.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.
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.
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:
[HEURISTIC].[HEURISTIC].[NEEDS-MANUAL].[NEEDS-MANUAL].sr-only span?); tag [HEURISTIC].<label htmlFor> exist nearby?); tag [HEURISTIC].[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.
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 configurationRead 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]."
Use the detection record to decide which downstream phases run:
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.tailwind: truereact: trueastro: trueradix: true or headlessui: truePhases 2, 3, 5, 6, 7 (semantic, ARIA, contrast/motion, forms, SVG) are framework-agnostic and run regardless of stack.
Check every component file for semantic HTML violations.
<h1> → <h3> with no <h2>)<h1> elements on a single page<h4> inside a card that is font-bold text-sm)<h1> on page-level components<main>, <header>, <nav>, or <footer> landmarks<nav> or <header> elements that lack unique aria-label or aria-labelledby<div id="content"> patterns that should be <main><div onClick> and <span onClick> without role and tabindex<div role="button"> when a <button> should be used<a> tags without href used as buttons<b> / <i> used for semantic emphasis (should be <strong> / <em>)<label> wrapping for icon-only inputs<div> inside <ul>, <ol>, <table> — suggest Fragments<img> without alt attribute<img alt="image">, <img alt="photo">, or file-name-as-alt anti-patterns<img> with redundant alt (same as adjacent caption text)alt="" or role="presentation"Check all ARIA attribute usage across components.
role="button" on <button> — redundantrole="heading" on <h1>–<h6> — redundantrole="list" on <ul> / <ol> — only needed in some browsers with Tailwind list-nonearia-hidden="true" on any element that is focusable or contains focusable childrenaria-hidden ancestoraria-hidden="true" when decorative (or inside a labeled button)<button> with no text content, no aria-label, and no aria-labelledby<button><svg>...</svg></button> with no accessible name<a> with no text, no aria-label, no aria-labelledby<input type="image"> without altaria-expanded attributes hardcoded to "true" or "false" that are not driven by statearia-expanded entirelyaria-checked, aria-selected, aria-pressed not connected to component statearia-expanded="true" — should be aria-expanded={isOpen}aria-live, role="status", or role="alert"role="alert" used for non-urgent messages (should be role="status")Check keyboard operability across all interactive components.
outline: none, outline: 0, or Tailwind outline-none that is not immediately followed by a custom focus indicatorfocus:outline-none without focus-visible:ring-* replacement*:focus { outline: none } global CSS reset without a replacementtabindex values greater than 0 (breaks natural DOM order)order, float, or position: absoluteEscape key handlerTab without providing a clear exit<div role="button"> / <div tabindex="0"> missing keydown/keyup handlers for Enter and SpaceuseEffect that removes a modal/panel from the DOM without calling .focus() on the trigger elementsetIsOpen(false) or onClose() without a preceding triggerRef.current?.focus()Check Tailwind color utility classes and CSS color values for likely contrast failures:
text-gray-400 bg-white, text-yellow-300 bg-white)border-gray-200, border-gray-300) on white backgrounds — likely fail 3:1::placeholder) styled with low-contrast colorsanimation or transition on non-UI state changes (page decorations, hero animations) without @media (prefers-reduced-motion: reduce) overrideanimate-spin, animate-bounce, animate-pulse) applied to non-status elements without motion-reduce: or motion-safe: modifierw-4 h-4, p-0, text-xs buttons that are icon-onlyCheck all form components.
<input>, <select>, <textarea> without an associated <label> (via for/id or wrapping)htmlFor in React that does not match an id on any inputplaceholder used as the only labeling mechanismrequired attribute or aria-required="true"aria-describedbyaria-invalid not set to "true" on invalid inputs during validation<fieldset> + <legend><legend> used but empty or containing only whitespaceCheck all SVG usage.
<svg> without aria-hidden="true" when inside a labeled button or link (screen readers will attempt to read raw SVG content)<svg> without focusable="false" in IE/Edge-compatible codebasesrole="img" and either aria-label or a hidden text alternative<title> but no aria-labelledby pointing to it (cross-browser reliability issue)outline-none without focus-visible:ring-* replacement (most common Tailwind a11y failure)sr-only on icon-only button text labelsmotion-safe: / motion-reduce: usage on animated elementslist-none on <ul> — some screen readers strip list semantics; recommend role="list" if semantics matteraria-* props use JSX booleans correctly: aria-expanded={isOpen} not aria-expanded="true"key props on focusable list items that change order (forces DOM remount, loses focus position)dangerouslySetInnerHTML in components — requires manual a11y verification of injected contentclient:visible or client:idle on keyboard-interactive components — risk of hydration dead zonesdata-* attributes for components that render with client:visiblesrc/layouts/*.astro)asChild usage spreads all props and forwards refs — missing ref breaks Radix focus managementDialog.Content has aria-labelledby pointing to Dialog.TitleGroup 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.
[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.[STATIC])Issues that make the UI completely unusable for keyboard or screen reader users.
[HEURISTIC] from source; needs runtime confirmationoutline-none with no replacement) — [STATIC]aria-hidden="true" on focusable elements — [STATIC]<div> with no keyboard handler — [STATIC]Issues that significantly impair assistive technology users.
[STATIC] (presence) / [HEURISTIC] (correct association)[STATIC][STATIC][STATIC] (missing markup) / [NEEDS-MANUAL] (timing)Issues that degrade the experience for some users.
[STATIC][STATIC][STATIC][STATIC]Low-impact issues or recommendations.
lang on <html> — [STATIC][HEURISTIC]tabindex > 0 values — [STATIC]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">]
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>
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:
id after dynamic component renderingThis static skill catches but real axe often misses:
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)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 updatesclient: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.
npx claudepluginhub radorigin-llc/rad-claude-skills --plugin rad-a11yProvides UI/UX resources: 50+ styles, color palettes, font pairings, guidelines, charts for web/mobile across React, Next.js, Vue, Svelte, Tailwind, React Native, Flutter. Aids planning, building, reviewing interfaces.
Fetches up-to-date documentation from Context7 for libraries and frameworks like React, Next.js, Prisma. Use for setup questions, API references, and code examples.