accessibility-audit
Audit a component or page for accessibility violations against WCAG 2.1 AA criteria. Use when asked for an accessibility audit, a11y check, WCAG compliance check, or "is X accessible".
SKILL.md.claude/skills/accessibility-audit/SKILL.md
GitHubaccessibility-audit
Audit a component or page for accessibility violations against WCAG 2.1 AA criteria.
When invoked
Use this skill when asked to check accessibility, run an a11y audit, or find WCAG issues — phrases like "accessibility audit", "a11y check on [component/page]", "check WCAG compliance", "is [X] accessible".
What is already automated — read this before auditing anything
Axe runs against the served site and fails the build. scripts/validate-website-a11y.mjs loads the route sample in scripts/served-site.mjs in both themes and enforces WCAG 2.1 AA, so npm run verify and CI already cover it. It used to run over every Storybook story too; that gate went to the design system's repo with the library, so this site's own pages are the whole surface now.
npm run verify
If that is green, every violation axe can detect is already absent — and re-checking icon-only button names, label association, role="dialog" naming or ARIA parent/child relationships by hand is duplicated effort.
This skill exists for the three things that gate does not cover:
- Colour contrast — excluded from the automated gate entirely.
color-contrastis switched off inscripts/validate-website-a11y.mjsby a settled decision of Rob's. Read that override's comment first: it is the authoritative record of why, and the single place the details belong. Since the 2026-08-20 accessible-teal split the shipped pairs the rule used to fail all clear AA; the rule stays off by that settled decision. That makes contrast — every pair, everywhere — the manual auditor's job, and the single highest-value thing to audit: nothing else checks it. Never propose re-enabling the rule without asking Rob first. - What axe cannot see. Axe catches roughly a third of WCAG issues. It cannot tell whether alt text is meaningful, whether focus order makes sense, or whether a helper message should have been associated with its control. (Overlay behaviour such as the focus trap, restore, and Escape is tested in the rift-ds repo, not here; on this site, check that the composed page still behaves.)
- Website pages beyond the automated sample. A page-level axe pass (
scripts/validate-website-a11y.mjs, inverifyand CI) now runs against the served site in both themes with the same rule set — but only over the route sample inscripts/served-site.mjs, at one desktop viewport. Full-site sweeps, mobile viewports, and any page outside that sample remain this skill's job.
Report a finding as already-enforced if the page-level axe pass would have caught it; that tells the reader the gate is working rather than implying a gap.
Instructions
Determine scope. Accept one of:
- A website page URL (e.g.
/work/<slug>) → audits the live rendered page
- A website page URL (e.g.
Read the source files. For each page in scope, read its
page.tsxandpage.module.cssbefore taking screenshots.Structural audit (from source code). Most items here are already enforced by axe — spend your effort on the ones marked [manual], which it cannot evaluate:
Semantic HTML & ARIA:
- Interactive elements use correct roles (
button,link,checkbox, etc.) — never a<div onClick>withoutroleandtabIndex - Icon-only
<button>elements havearia-labeldescribing their action - [manual]
<img>elements have meaningfulalttext — axe only checks that the attribute exists; decorative images usealt="" - Form inputs are associated with
<label>viahtmlFor/id, or havearia-label - Modals and dialogs use
role="dialog"andaria-modal="true", witharia-labelledbypointing to the title - Lists use
<ul>/<ol>+<li>, not<div>stacks - Heading hierarchy is logical — no h3 before h2, no skipped levels
Keyboard Navigation:
- [manual] All interactive elements are reachable by Tab, in an order that makes sense — axe cannot judge order
- Custom interactive components handle
onKeyDownfor Enter/Space (buttons) and arrow keys (any component with roving or list focus — radio groups, segmented controls, listboxes, tablists) - [manual] Modal/dialog actually traps focus while open and restores it to the trigger on close — axe sees the attributes, not the behaviour. The modal overlays come from
rift-dsand share one behavior implementation there, so a real trap/restore defect is a finding against the package, to report rather than patch here - Escape key closes dismissible overlays (any floating panel — tooltips, popovers, menus, dialogs, pickers)
Focus Styles:
- Every interactive element has a
:focus-visiblerule in its CSS - Focus ring uses the teal action token (
--color-action-primary-bg) — the design system's spec in the rift-ds repo owns the sanctioned uses. Flag anyoutline: nonewithout a visible replacement
Motion (axe evaluates none of this):
- [manual] Anything that animates for more than five seconds, or loops indefinitely, can be paused, stopped, or hidden (WCAG 2.2.2). CSS motion satisfies this through the reduced-motion block in the
rift-dspackage'stokens-motion.css, which carries two mechanisms and whose own header comment owns the contract: token collapse for token-consuming CSS, and a universal*guard that also covers hardcoded andds-allow(motion)-sanctioned literal timings (an infinite loop on an off-scale duration is still guarded). Read it there rather than assuming which half applies - [manual] Animation driven from JavaScript is outside that guard — a
requestAnimationFrameloop cannot be seen by CSS, so each one has to checkprefers-reduced-motionitself. Enumerate the JS-side checks with a grep forprefers-reduced-motionacrosswebsite/src— the grep is the inventory, not any list written here (the site's ambient background, which renders a single static frame under the preference, is one illustration; more exist and new ones keep arriving) — and confirm each still honours the preference rather than assuming the token layer covers it. - [manual] Motion triggered by interaction (parallax, cursor-reactive effects) is disabled under reduced motion, or is not essential (WCAG 2.3.3)
- Interactive elements use correct roles (
Visual audit (from screenshots). Start the preview server and screenshot the target in both light and dark mode (follow the
visual-reviewskill pattern). Check:- Colour contrast (the priority — nothing automated covers this): compute the ratio for every foreground/background pair actually rendered, not just body text. Flag anything below 4.5:1 for normal text or 3:1 for large text and UI components (WCAG 1.4.3 / 1.4.11). Note which token is used. Nothing is pre-exempt: the
color-contrastaxe rule is off for every pair (see point 1 under What is already automated), so every rendered pair is this audit's job; the shipped action pairings' AA clearance is recorded in that override's comment, so cross-check against it before flagging one of them. - Text sizing: No text visually below ~12px (WCAG 1.4.4)
- Focus visibility: Confirm focus rings are clearly visible in both light and dark themes
Stop the server when done.
- Colour contrast (the priority — nothing automated covers this): compute the ratio for every foreground/background pair actually rendered, not just body text. Flag anything below 4.5:1 for normal text or 3:1 for large text and UI components (WCAG 1.4.3 / 1.4.11). Note which token is used. Nothing is pre-exempt: the
For each issue, report:
website/src/components/FilterMenu/FilterMenu.tsx:84 — WCAG 4.1.2 Name, Role, Value [Critical] Trigger button has no accessible name. Icon-only button needs aria-label="Open filters".(The example is fictional by design —
FilterMenuis not a real component; it illustrates the report shape only.)Severity:
- Critical — blocks keyboard or screen reader users entirely
- Moderate — degrades experience significantly
- Minor — best practice violation, low direct impact
Summarise:
X critical · Y moderate · Z minor- If clean: "No accessibility violations found. Page meets WCAG 2.1 AA."