Install any skill in seconds. Free to start, no credit card required.
Get Started Free →Reference-grade guide to shipping WCAG 2.2 AA accessible, inclusively designed interfaces across web, native iOS/Android, and desktop — semantic-first markup, contrast/APCA, keyboard, screen readers, forms, touch/motor, motion/cognition, testing, and the legal floor (EAA/Section 508).
.claude/skills/jpoindexter-accessibility-and-inclusive-design/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-01 | ✗→✓ | ▲ Improved | 109% | 0% |
| case-13 | ✗→✓ | ▲ Improved | 564% | 0% |
| case-07 | ✓→✓ | = Same ✓ | 291% | 0% |
| case-02 | ✓→✓ | = Same ✓ | 299% | 0% |
| case-03 | ✓→✓ | = Same ✓ | 242% | 0% |
Accessibility is not a feature toggle or a final-pass audit — it is a property of correct UI construction. Build it in or rebuild it later. This is the bar for production: WCAG 2.2 Level AA, every screen, every platform. Automated tools catch ~30% of issues; the rest is keyboard, screen reader, and judgment. Treat this document as the working reference, not an aspiration.
WCAG is organized under four principles. Memorize them; every requirement maps to one.
| Principle | Means | Failure example | |---|---|---| | Perceivable | Users can perceive the content (sight, sound, touch) | Image with no alt; 2:1 contrast text | | Operable | Users can operate the UI (any input method) | Control reachable by mouse only | | Understandable | Content and operation are predictable | Error says "invalid input" with no fix | | Robust | Works with current and future assistive tech (AT) | Custom <div> widget with no role/state |
Conformance levels: A (must — basics, keyboard, alt text), AA (the legal/industry target — contrast, reflow, focus, names), AAA (aspirational, not required wholesale — 7:1 contrast, sign language). Ship AA. Conformance is per-page and all-or-nothing: a page conforms only if every applicable success criterion (SC) at that level is met, with no AT-blocking content anywhere in the page or in any process (e.g. checkout) it belongs to.
These are the ones teams miss because they post-date most a11y muscle memory. All are AA unless noted.
| SC | Name | Requirement | |---|---|---| | 2.4.11 | Focus Not Obscured (Minimum) | When an element gets keyboard focus, it is not entirely hidden by author content (sticky headers, cookie bars, chat widgets). | | 2.4.12 | Focus Not Obscured (Enhanced) | AAA — focus indicator not obscured at all. | | 2.4.13 | Focus Appearance | The focus indicator is at least as large as a 2px-thick perimeter of the component and has ≥3:1 contrast between focused/unfocused states. (AAA, but treat as the design target.) | | 2.5.7 | Dragging Movements | Any drag operation has a single-pointer alternative (e.g. click-to-move, tap targets, up/down buttons) unless dragging is essential. | | 2.5.8 | Target Size (Minimum) | Interactive targets are ≥24×24 CSS px, OR have ≥24px spacing to neighbors, OR are inline in a sentence. (AAA 2.5.5 wants 44px.) | | 3.2.6 | Consistent Help | If help (contact, chat, FAQ link) appears on multiple pages, it appears in the same relative order each time. | | 3.3.7 | Redundant Entry | Don't make users re-enter info they already gave in the same process — autofill it or let them select it. | | 3.3.8 | Accessible Authentication (Minimum) | No cognitive function test (memorize/transcribe a password, solve a puzzle, identify objects) as the only way to authenticate. Allow password managers (don't block paste), email/OTP, passkeys, WebAuthn. |
> One SC (4.1.1 Parsing) was removed in 2.2 — modern parsers handle duplicate IDs/malformed markup. Still write valid HTML; it just isn't a conformance line anymore.
Color is the most-failed category in automated scans. Two independent rules: contrast ratios, and never-color-alone.
| Content | Min ratio (AA) | AAA | |---|---|---| | Normal text (<18.66px / <24px) | 4.5:1 | 7:1 | | Large text (≥24px, or ≥18.66px bold) | 3:1 | 4.5:1 | | Non-text (icons, input borders, focus rings, chart series, toggle states) — SC 1.4.11 | 3:1 | — | | Disabled controls / pure decoration | exempt | — |
css/* FAIL: #999 on #fff = 2.85:1 — fails normal text */ .muted { color: #999; } /* PASS: #767676 on #fff = 4.54:1 */ .muted { color: #767676; } /* Non-text: a 1px #ddd input border is ~1.2:1 → invisible to low vision. Use ≥3:1. */ input { border: 1px solid #767676; }
* + text, not just a red label. Error state: icon + message, not just a red border. Chart series: pattern/label/direct annotation, not hue only. Link in body text: underline it, don't rely on blue.If it works with a mouse but not a keyboard, it is broken. Keyboard access is the foundation for switch devices, voice control, and most screen-reader use.
Tab/Shift+Tab to move, Enter/Space to activate (Space for buttons/checkboxes, Enter for links), arrows within composite widgets, Esc to dismiss.Esc (or a close button) must release it.order, absolute positioning) in a way that diverges from DOM — it desyncs tab order from what's seen.outline: none without a replacement.css/* Use :focus-visible so mouse clicks don't show a ring but keyboard does. */ :focus-visible { outline: 3px solid #1a73e8; /* ≥3:1 vs adjacent colors (2.4.11/2.4.13) */ outline-offset: 2px; /* ≥2px perimeter, fully visible */ } /* Forbidden: */ button:focus { outline: none; } /* removes the only signal for keyboard users */
tabindex rules: 0 = in natural order; -1 = focusable by script only (not by Tab — for managing focus in widgets/dialogs); never use positive values — they hijack global order and create chaos.<main>. Visible on focus:html<a href="#main" class="skip-link">Skip to main content</a> <!-- … nav … --> <main id="main" tabindex="-1"> … </main>
css.skip-link { position: absolute; left: -9999px; } .skip-link:focus { left: 1rem; top: 1rem; /* on-screen, high contrast */ }
Tab/Shift+Tab to focusable children (wrap from last→first and first→last); on close, return focus to the opener; close on Esc. Mark background inert with inert attribute (or aria-hidden="true" on siblings).tabindex="0", the rest -1; arrow keys move the 0 and call .focus(). The whole widget is one tab stop. (Alternative: aria-activedescendant.)j/k) must be remappable, toggleable off, or active only on focus — or they fire while typing in fields and disrupt voice/AT users.The first rule of ARIA: don't use ARIA. A native element with the right semantics beats any ARIA you'll bolt on. Reach for ARIA only when HTML genuinely can't express the pattern. Bad ARIA is worse than none.
<header>, <nav>, <main> (one per page), <aside>, <footer>, <form>, <section aria-label>. Screen-reader users jump between them.<h1>–<h6> are the #1 navigation tool for SR users. One <h1> per page; never skip levels (no <h2>→<h4>); style with CSS, not heading rank. Don't fake a heading with bold text.<ul>/<ol>/<dl>) announce item counts ("list, 5 items"). Group repeated items as a list.<button> performs an action (submit, toggle, open dialog); <a href> navigates to a URL/location. A <div onclick> is not focusable, not keyboard-operable, and announces nothing. Never reinvent these.html<!-- WRONG: not focusable, no role, no keyboard, no announcement --> <div class="btn" onclick="save()">Save</div> <!-- RIGHT --> <button type="button" onclick="save()">Save</button>
Precedence (later overrides earlier): aria-labelledby → aria-label → native (<label>, alt, text content) → title.
html<!-- visible text IS the name — best --> <button>Delete invoice</button> <!-- icon-only button needs an explicit name --> <button aria-label="Close dialog"><svg aria-hidden="true">…</svg></button> <!-- point at existing visible text --> <h2 id="prefs">Notification preferences</h2> <section aria-labelledby="prefs">…</section> <!-- extra detail, announced after the name --> <input aria-describedby="pw-hint"> <p id="pw-hint">At least 12 characters.</p>
alt="describes content/function"; decorative → alt="" (empty, not missing — missing makes SRs read the filename); complex (charts) → short alt + long description nearby or via aria-describedby. Alt for a linked image describes the destination/action, not the picture.aria-live="polite" (waits for a pause — status, "Saved", search-result counts), aria-live="assertive" (interrupts — errors, time-critical). Roles role="status" (=polite) and role="alert" (=assertive) are shorthand. The container must exist empty in the DOM before you inject text. Don't overuse assertive — it's rude and disorienting.| ARIA | Use | |---|---| | aria-expanded="true/false" | Disclosure, accordion, menu, combobox trigger | | aria-selected="true/false" | Tabs, options, grid cells | | aria-checked="true/false/mixed" | Custom checkbox/radio/switch (mixed = indeterminate) | | aria-disabled="true" | Disabled but still in AT tree (unlike disabled, stays announced/focusable) | | aria-invalid="true" | Field failing validation; pair with aria-describedby → error text | | aria-current="page/step/true" | Current item in a set (nav, breadcrumb, pagination) | | aria-hidden="true" | Remove from AT tree (decorative icons) — never on focusable content | | aria-pressed="true/false" | Toggle button | | aria-controls / aria-owns | Relate trigger to the thing it controls |
role only when HTML can't. role="tablist/tab/tabpanel", role="dialog", role="menu" etc. carry obligations: if you take the role, you owe the full keyboard interaction model and state management for it (see WAI-ARIA Authoring Practices Guide / APG). A half-built role="tab" is worse than three <a> links.Forms are where most real-world a11y fails for users with cognitive, motor, or vision needs.
html<!-- WRONG --> <input type="email" placeholder="Email"> <!-- RIGHT: explicit association via for/id --> <label for="email">Email address</label> <input id="email" type="email" name="email" autocomplete="email" required aria-describedby="email-err"> <p id="email-err" role="alert" hidden>Enter a valid email, e.g. name@site.com</p>
aria-invalid="true" and link the message with aria-describedby. On submit, move focus to the first error (or a summary role="alert" listing each error as a link to its field).required native attribute conveys requirement to AT; aria-required="true" for custom controls. Pair with a visible "(required)" or marked optional — don't rely on * alone (announce it: include "required" in the label, or aria-label).autocomplete (SC 1.3.5) with standard tokens (name, email, tel, street-address, cc-number, one-time-code) lets browsers/password managers/AT fill fields — critical for motor and cognitive users, and required by SC 3.3.8.<fieldset> with a <legend> — the legend is announced with each control so the group context isn't lost.aria-live regions or role="alert" so the change is announced. Never disable the submit button as the only error feedback — SR users can't tell why it's dead.up/click, not down — so a user who presses the wrong target can slide off to cancel. No mousedown/touchstart triggers for destructive actions.target-size is per-pointer, but mouse precision varies too (Parkinson's, RSI) — generous targets help everyone.css@media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; transition-duration: 0.01ms !important; scroll-behavior: auto !important; } }
rem/em, never block zoom (user-scalable=no is a violation), and respect text spacing overrides (SC 1.4.12).The principles are identical; the APIs differ. Lean on platform primitives — they ship correct semantics for free.
iOS — VoiceOver (UIKit/SwiftUI):
UIButton, UISwitch) — they carry roles. Set accessibilityLabel (name), accessibilityHint (what happens), accessibilityValue (current value), and accessibilityTraits (.button, .header, .selected, .adjustable). SwiftUI: .accessibilityLabel(), .accessibilityHint(), .accessibilityAddTraits(), .accessibilityValue()..body, .headline) so text scales to the user's size; test at the largest accessibility sizes; don't hardcode font sizes or clip at large scales. Support Bold Text, Reduce Motion (UIAccessibility.isReduceMotionEnabled), and Increase Contrast.accessibilityElement(children: .combine)), order with accessibilitySortPriority, mark decorative views hidden, expose custom actions via the rotor.Android — TalkBack (Views/Compose):
contentDescription for non-text controls (null for decorative). Compose: Modifier.semantics { contentDescription = … }, Modifier.clickable (gives role/focus), Role.Button. Use stateDescription for toggles, heading() for headings, liveRegion for announcements.sp units, never dp/px for text), touch targets ≥48dp (minimumInteractiveComponentSize), and the Remove Animations setting. Use mergeDescendants to group, traversalIndex for order.Desktop (macOS/Windows/Linux & Electron): native apps inherit the OS accessibility tree (NSAccessibility, UI Automation, AT-SPI) when using standard controls — label custom controls explicitly. Electron/web-in-shell apps follow all the web rules above; also ensure full keyboard menus, OS high-contrast themes, and OS reduce-motion are respected.
Automated tools find ~30% of issues — they cannot judge alt-text quality, focus order, name accuracy, or whether a custom widget makes sense. Manual testing is mandatory.
Cmd+F5, iOS), NVDA (Windows, free), TalkBack (Android), JAWS (enterprise Windows). Navigate by heading, landmark, link, form field. Are names, roles, states announced correctly? Test in the OS-native pairing (VoiceOver+Safari, NVDA+Firefox/Chrome, TalkBack+Chrome) — combos differ.@axe-core/playwright in CI), Lighthouse (a11y category), Pa11y, WAVE. Run in CI as a regression gate — but never as the whole strategy.WCAG is the floor, not the goal. Inclusive design widens who can use the product and makes it better for everyone.
| Mistake | Why it breaks | Fix | |---|---|---| | Placeholder as the label | Vanishes on input, low contrast, no AT name | Real <label for> | | <div>/<span> as a button | Not focusable, no role, no keyboard | <button> | | outline: none with no replacement | Keyboard users lose all focus signal | :focus-visible ring ≥2px, ≥3:1 | | ARIA on top of broken HTML | Conflicting/duplicate semantics confuse AT | Fix the HTML; remove the ARIA | | role without the keyboard model | Announces "tab" but arrows/state don't work | Implement full APG pattern or use links | | Color-only meaning (red = error) | Invisible to color-blind/low-vision users | Add icon + text | | Contrast fails (#999, thin grey borders) | Unreadable for low vision; fails 1.4.3/1.4.11 | 4.5:1 text, 3:1 non-text | | Motion with no opt-out | Triggers vestibular disorders/migraine | prefers-reduced-motion + pause control | | Icon-only control, no name | Announces nothing / reads filename | aria-label + aria-hidden on the glyph | | Focus hidden behind sticky header | Keyboard user can't see where they are | scroll-margin, respect SC 2.4.11 | | Empty/missing alt confusion | Missing → filename read aloud; wrong → noise | alt="" decorative, descriptive otherwise | | aria-hidden on focusable content | Element is operable but silent to AT | Never hide focusable nodes | | Auto-advancing carousel, no controls | Moving content, fails 2.2.2 | Pause/prev/next, stop on focus/hover | | Skipped heading levels / multiple h1 | Breaks SR document outline navigation | One h1, sequential levels, style via CSS | | Drag-only reorder, swipe-only nav | Fails 2.5.7 / 2.5.1 for motor users | Add single-pointer/button alternative | | Blocking paste / puzzle CAPTCHA login | Fails 3.3.8, defeats password managers | Allow paste, OTP/passkeys, no cognitive test |
Working order for any screen: semantic HTML → accessible names → keyboard pass → visible focus → contrast → states/live regions → screen-reader pass → zoom/reflow → reduced motion → automated scan as the backstop. Build it in; don't bolt it on.
| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-07 | pass→pass | 13,310 | 13,386 | +1% | 1 | 1 | 0% | 2,417 | 9,461 | +291% | 0 | 0 | — |
case-01 | fail→pass | 34,261 | 31,526 | -8% | 1 | 1 | 0% | 6,212 | 12,967 | +109% | 0 | 0 | — |
case-13 | fail→pass | 6,942 | 9,117 | +31% | 1 | 1 | 0% | 1,264 | 8,395 | +564% | 0 | 0 | — |
case-02 | pass→pass | 12,079 | 10,692 | -11% | 1 | 1 | 0% | 2,238 | 8,919 | +299% | 0 | 0 | — |
case-03 | pass→pass | 16,115 | 17,151 | +6% | 1 | 1 | 0% | 2,830 | 9,692 | +242% | 0 | 0 | — |
case-04 | pass→pass | 7,789 | 6,558 | -16% | 1 | 1 | 0% | 1,628 | 8,050 | +394% | 0 | 0 | — |
case-05 | pass→pass | 11,165 | 13,343 | +20% | 1 | 1 | 0% | 2,063 | 9,235 | +348% | 0 | 0 | — |
case-06 | pass→pass | 15,963 | 24,323 | +52% | 1 | 1 | 0% | 2,009 | 8,925 | +344% | 0 | 0 | — |
case-08 | pass→pass | 11,218 | 12,951 | +15% | 1 | 1 | 0% | 2,090 | 9,235 | +342% | 0 | 0 | — |
case-09 | pass→pass | 12,028 | 11,968 | -0% | 1 | 1 | 0% | 2,305 | 8,896 | +286% | 0 | 0 | — |
case-10 | pass→pass | 23,705 | 14,991 | -37% | 1 | 1 | 0% | 2,605 | 9,636 | +270% | 0 | 0 | — |
case-11 | pass→pass | 14,070 | 24,500 | +74% | 1 | 1 | 0% | 2,670 | 9,899 | +271% | 0 | 0 | — |
case-12 | pass→pass | 11,616 | 11,562 | -0% | 1 | 1 | 0% | 2,121 | 8,897 | +319% | 0 | 0 | — |
case-14 | pass→pass | 15,403 | 16,242 | +5% | 1 | 1 | 0% | 2,782 | 9,644 | +247% | 0 | 0 | — |
case-15 | pass→pass | 13,332 | 15,986 | +20% | 1 | 1 | 0% | 2,285 | 9,630 | +321% | 0 | 0 | — |
case-16 | pass→pass | 11,294 | 14,487 | +28% | 1 | 1 | 0% | 2,393 | 9,034 | +278% | 0 | 0 | — |
case-17 | pass→pass | 12,227 | 13,300 | +9% | 1 | 1 | 0% | 2,192 | 8,883 | +305% | 0 | 0 | — |
case-18 | pass→pass | 10,857 | 11,178 | +3% | 1 | 1 | 0% | 1,948 | 8,748 | +349% | 0 | 0 | — |
case-19 | pass→pass | 10,186 | 10,254 | +1% | 1 | 1 | 0% | 1,858 | 8,567 | +361% | 0 | 0 | — |
case-20 | pass→pass | 15,344 | 18,034 | +18% | 1 | 1 | 0% | 2,834 | 10,059 | +255% | 0 | 0 | — |
case-21 | pass→pass | 14,970 | 17,648 | +18% | 1 | 1 | 0% | 2,594 | 10,112 | +290% | 0 | 0 | — |
case-22 | pass→pass | 5,774 | 13,979 | +142% | 1 | 1 | 0% | 1,149 | 8,127 | +607% | 0 | 0 | — |
case-23 | pass→pass | 12,170 | 12,378 | +2% | 1 | 1 | 0% | 2,188 | 8,942 | +309% | 0 | 0 | — |
case-24 | pass→pass | 7,394 | 7,204 | -3% | 1 | 1 | 0% | 1,514 | 8,084 | +434% | 0 | 0 | — |
DecimalAI ran this skill against gemini-3.6-flash twice over the same eval suite — once with the skill loaded and once without — and compared the two runs case by case. 24 cases were attempted. The headline lift of +8 percentage points is the difference between those two pass rates over the 24 comparable cases.
Without the skill loaded, the model failed this case. With it loaded, the same prompt on the same model passed. This is one improved case from the latest verified run; every case, including any that regressed, is in the table above.
| Model | Method | Date | Lift |
|---|---|---|---|
| gemini-3.6-flash | verified | 8/3/2026 | — |
Other measured skills in the registry, with their headline benchmark lift.