Every control is reachable and named for VoiceOver and TalkBack in every locale the app ships, every target's own box is at least 44 pt, text grows to 200 % without clipping, every colour pair the UI and its canvases paint is declared once and meets WCAG 2.2 AA in every colour set, motion follows one live reduce-motion source, every swipe, drag or hold has a tap or a named action, custom canvases speak a summary, and overlays keep both screen readers inside. Scripts and tests prove what a machine can see; a human screen-reader pass covers the rest.
Rules that must hold
- Give every interactive element a role and a translated name (
t(), asaccessibilityLabeloraria-label;aria-labelledbyalone names it on Android and the web only), a hint only when the outcome is not obvious (the result, never the gesture), and its state (accessibilityStateoraria-*; React Native'sPressableand Touchables derivedisabledthemselves, Gesture Handler's do not). A pressable no screen reader should focus (a backdrop) isaccessible={false}and its action exists elsewhere. Why: both screen readers read role, name and state, andgetByRolefinds exactly that. - Make every touch target's own box at least 44 × 44 pt (
a11y.minTouchPt, 48 for Material); neverhitSlop. Custom hit regions prove their smallest cell in a unit test. Why:hitSlopis invisible in layout, overlaps neighbours and does not move the screen reader's focus frame. - Let text grow to at least 200 %: never
allowFontScaling={false}; anymaxFontSizeMultipliersits on the text primitive and is never below the cap (a11y.maxFontScale, default 2);minHeightinstead ofheight; nonumberOfLineson sentences; rows stack whenfontScale >= 1.25(iOS XXXL and up, Android 1.3 and up: Android reports 1.3 as 1.2999999523). Why: WCAG 1.4.4, and a clipped or truncated label is unreadable. - Declare every colour pair the UI paints in
contracts/contrast.json, the app's one pair table (the theme palette and every other colour table: charts, tags; a Skia canvas surface keeps its pairs in its owncanvas-contrast.json), and meet WCAG 2.2 AA in every colour set: text 4.5:1, icons, edges, focus and chart lines 3:1, a filled shape 3:1 off its edge or its ground; translucent colours composited first. Why: a palette never says which colour sits on which, and design-system-structure's palette tools read the same table, so it is kept once. - Never convey meaning by colour alone. Every state carries an icon, shape or word, and
colours that alone tell categories apart (
distinctlists: chart series, tags) stay 0.07 apart in OKLab under protanopia, deuteranopia and tritanopia. Why: about 1 in 12 men cannot tell red from green, and status colours collide (0.013 apart under deuteranopia). - Reduce motion only through
useReduceMotion()and<MotionConfig>; never Reanimated'suseReducedMotion()orAccessibilityInfooutsidesystemA11yStore; every moment that moves has a reduced alternative; functional timers passReduceMotion.Never. Why: Reanimated's hook is a module-load constant that misses live changes and the in-app setting. - Expose each information-bearing canvas as one image labelled with
t()of a puredescribe(state), or mark it decorative; announce events withuseAnnounce()(one short sentence per action, only while a screen reader runs, queued so a screen change cannot cut it off; a live region for status text on Android). Why: Skia content is invisible to screen readers, and stray announcements talk over the user. - Keep overlays (dialogs, sheets, menus, popovers, drawers) modal on both platforms and reading
order visual: Expo Router modal routes or RN
<Modal>first; an in-tree overlay setsaccessibilityViewIsModalon its root (the scrim, never the card;aria-modalworks only onPressableand TouchableOpacity/Highlight/NativeFeedback, and on the web) and hides what is behind it on Android (aria-hiddenon the screen's side, not inside the layer) in the same component; an in-tree overlay host wraps the navigator, so the header and tab bar are hidden too. Never nest a pressable in another: a card's opener and action are siblings, and the text layer drawn above the opener haspointerEvents: 'none', both in one sharedsrc/ui/card component (onlysrc/ui/may importPressable). Sliders are adjustable with declared actions; other-language text setsaccessibilityLanguage. Why: otherwise focus wanders behind dialogs, inner actions are unreachable, taps on a card's text do nothing and sliders cannot move. - Give every gesture-only action (long press, swipe, drag, pinch) one tap or a named action:
accessibilityActionswith translated labels andonAccessibilityActionon the focused element (the VoiceOver Actions rotor, the TalkBack actions menu), plus a single-pointer way for drags. An essential gesture is a revieweda11yGestureExceptionsentry, never a dummy action. Why: both screen readers take over swipes to move focus (WCAG 2.5.1, 2.5.7). - Query by role and name in tests, and end every screen test with the
findInaccessiblePressablesaudit. Why: a missing role or name then fails a test, not a user. - Run a human VoiceOver and TalkBack pass on every release candidate, in one left-to-right and (when shipped) one right-to-left locale; ask for it, never claim it, and turn every reported problem into a failing test first. Why: no checker can hear whether the spoken output makes sense.
- Never weaken a pair, a gate or a check to go green. An accepted failure is a contract
exception with a
reasonandapprovedBy, added only after the developer agrees, and printed on every run. Why: a silenced contrast failure ships unreadable text.
Workflow
- First time. Copy
templates/src/ui/a11y/tosrc/ui/a11y/andtemplates/test/a11y/totest/a11y/. Start the store once in the root layout (src/app/_layout.tsx):useEffect(() => watchSystemA11y(reportError), []), and wrap the app in<MotionConfig preference={reduceMotionPreference}>("system"when the app has no setting; settings-and-preferences owns a saved one); render everything that readsuseReduceMotion()below it. motion-and-animation'scheck-motionproves this wiring (reduce-motion-root). Make surequality-gates.jsonhas thea11yblock (minTouchPt44,maxFontScale2,textContrastMin4.5,nonTextContrastMin3,minCvdDistanceOk0.07; quality-gates owns the file). - Contrast contract. Copy
templates/contracts/contrast.jsonand its schema tocontracts/. Itsappentry describes design-system-structure's palette (src/theme/palette.ts, synced here withcontrast.tsandtheme-types.ts; copy those three only when the app has none), and that skill'swrite-paletteandcheck-design-systemread the same entry. Add a pair whenever a component puts a colour on a new ground (a label over a growing fill needs one per ground), and a palette entry for every other colour table (charts, tags). Read references/contrast-and-colour.md. - Building or changing a component or screen. Read
references/roles-names-and-screen-readers.md
and imitate examples/accessible-task-row.md: shared
src/ui/controls, names fromt()in the model hook, the right role and state, a 44 pt box, headers via the text primitive, a card's opener and action as siblings with the text layer passing touches through (opener and text layer in onesrc/ui/card the feature composes), every swipe or drag action also a named accessibility action on a shared row, overlays modal on both platforms. - Large text and motion. Read
references/text-scaling-and-motion.md: stack at large
text,
minHeighteverywhere, taller line heights for scripts with tall glyphs, and the reduced alternative of each moving moment. - Custom canvases and announcements. Label each canvas from a pure
describe(state)throught()and unit-testdescribe(); announce results withuseAnnounce(), once per action. - Tests. Read references/testing-accessibility.md.
Query by role and name, assert state, end every screen test with the audit line, and keep
test/a11y/app-contrast.test.tsgreen. - Run the checks from the repo root, fix every
FAILline (each names the file, the rule and the fix) and rerun until both printRESULT: PASS:node ${CLAUDE_SKILL_DIR}/scripts/check-a11y-code.mjs .node ${CLAUDE_SKILL_DIR}/scripts/check-contrast.mjs .A contrast failure is fixed in the colours or taken to the developer (rule 12), never silenced.
- Large text on devices. Copy
templates/.maestro/flows/a11y/01-large-text-core-screens.yamlto.maestro/flows/a11y/, replace its testIDs with the app's, and run it at the largest size on a named iOS simulator and a named Android emulator (e2e-maestro's runner does it per locale; the commands by hand are in the testing reference). Read every screenshot for clipping, overlap and rows that should have stacked; reset the size afterwards. - Before a release (human step). Ask the developer to run the screen-reader checklist in the testing reference on the release candidate, on both platforms; turn every reported problem into a failing test first.
Definition of done
- Every pressable has a role, a translated name, its state, and an own box of at least the
minimum without
hitSlop; labels and hints come fromt()with ICU plurals. - Text scales to at least 200 %: no
allowFontScaling={false}, no lower cap, no fixed heights around text, rows stack at large text; the large-text flow's screenshots on iOS and Android show nothing clipped. - Reduce motion goes through
useReduceMotion()/<MotionConfig>; each moving moment has its reduced alternative; canvases are one labelled image or decorative; announcements useuseAnnounce(); overlays are modal on both platforms; no nested pressables, and card text layers pass touches to their opener. - Every long press, swipe, drag and pinch action is also a named accessibility action (and a single-pointer control for drags); any essential gesture is a reviewed exception.
-
contracts/contrast.jsondeclares every pair the UI paints, for the theme palette and every other colour table, and each canvas surface has itscanvas-contrast.json;test/a11y/app-contrast.test.tsand thesrc/ui/a11y/tests pass; every screen test ends with the audit line. - No contract exception without a reason and an approver; no gate below its floor.
- The developer has been asked for the release-candidate VoiceOver and TalkBack pass.
-
node ${CLAUDE_SKILL_DIR}/scripts/check-a11y-code.mjs .printsRESULT: PASS. -
node ${CLAUDE_SKILL_DIR}/scripts/check-contrast.mjs .printsRESULT: PASS(the one SKIP it may print while the app has no gates file:SKIP quality-gates.json [a11y-gates] not yet created (quality-gates creates it)).
Anti-patterns
- A raw
Pressablein a screen with a hard-coded English label. Use the sharedsrc/ui/controls and passt('…')from the model hook. hitSlop={10}on a 24 pt icon. Make the button's own box 44 pt and centre the glyph.- Turning text scaling off, or capping it at 1.3, because a layout breaks at 200 %. Stack the
row, use
minHeight, let it wrap. - Adding a contract exception, lowering a gate or deleting a pair to make the check pass. Change the colour (keep the hue, move the lightness), or stop and ask the developer.
- Red and green status dots with no icon or word. Give every state a shape cue; make only
colour-only categories a
distinctlist and pick them from Okabe-Ito. useReducedMotion()from Reanimated, or readingAccessibilityInfoin a screen. It misses live changes and the setting; useuseReduceMotion()and the store.- A second reduce-motion hook for screenshot runs. Pass
preference="on"to<MotionConfig>from the test-only setup instead. announceForAccessibilityon every tap or frame. One short announcement per action, only while a screen reader runs; a live region for status text on Android.- An action nested inside a card's
Pressable. The screen reader reads the outer element and never reaches the action; make the opener and the action siblings. - A card's text layer drawn above its opener without
pointerEvents: 'none'. The topmost view takes the touch, so tapping the title does nothing on a device while every RNTL test passes. Keep the opener and its text layer in onesrc/ui/card, never a shared opener with the layer in each feature. - Swipe-to-delete or drag-to-reorder with no
accessibilityActions. Screen-reader swipes move focus, so the action is unreachable; add named actions on the row's opener. accessibilityViewIsModalalone on an in-tree overlay, or on the card inside the scrim. TalkBack still reaches the screen behind, and a modal card hides the scrim's testID; put the flag on the scrim and hide the content behind witharia-hidden, or use RNModal.aria-modalalone on aView. React Native 0.86.3 drops it there (onlyPressableand TouchableOpacity/Highlight/NativeFeedback map it), so VoiceOver leaves the sheet; addaccessibilityViewIsModal. react-native-web honoursaria-modalandaria-labelledbyon a View, so a web-enabled app keepsaria-modalnext to the iOS flag (web-support).aria-hiddenon a grip or icon inside the sheet as the "Android half". It hides nothing behind the layer; hide the sibling that holds the screen (aria-hidden={isOpen}).disabledalone on a Gesture HandlerPressableorenabledon aRectButton. Gesture Handler turns the gesture off but never reports it, so VoiceOver reads an active button; addaccessibilityState={{ disabled }}(React Native's ownPressablederives it).- A second pair table (a test-local list, a copy in a design tool export). Two tables drift;
add the pair to
contracts/contrast.json, which this skill and design-system-structure both read. - Labelling every icon inside a row. Give the row one name; decorative parts stay silent.
- Reporting "VoiceOver works" without the human pass. Automated checks cannot hear the output.
Files in this skill
| File | What it is | Read/run when |
|---|---|---|
| references/roles-names-and-screen-readers.md | Pressable checklist, roles and states per element, names and plurals, 44 pt targets, sliders and holds, card opener plus sibling action, canvases, announcements and live regions, overlays on iOS and Android, languages, a platform table | Workflow steps 3 and 5 |
| references/contrast-and-colour.md | WCAG maths, the contract keys, filled shapes and translucent colours, never colour alone, the colour-blind check and its calibration, exceptions, focus and Increase Contrast, the Jest twin | Workflow step 2 |
| references/text-scaling-and-motion.md | iOS fontScale table, Android font scale and its non-linear curve, the cap, large-text layout, tall glyphs, one reduce-motion source, moments and their reduced alternatives, spring mass | Workflow step 4 |
| references/testing-accessibility.md | RNTL role queries and the audit, the tests to keep, large text with Maestro on a named simulator and emulator, inspector tools, the VoiceOver and TalkBack release checklist, report to test | Workflow steps 6, 8 and 9 |
| examples/accessible-task-row.md | A task row: plural labels from t(), a shared src/ui/ ActionCard (opener, text layer, action slot) the feature's row composes, shape cues, the audited test |
Workflow step 3 |
templates/src/ui/a11y/ |
system-a11y-store.ts (reduce motion, screen reader, Bold Text), use-reduce-motion.ts, motion-config.tsx, use-announce.ts (queued), each with its test; owned here and synced from the library (edit the canonical copy, never this one) |
Workflow step 1; copy to src/ui/a11y/ |
templates/src/theme/ |
contrast.ts, palette.ts, theme-types.ts from design-system-structure (synced; do not edit here): the WCAG maths and the palette the contract template describes |
Workflow step 2; copy only when the app has none |
templates/test/a11y/ |
find-inaccessible-pressables.ts (+ test), cvd.ts, contrast-contract.ts (+ test), app-contrast.test.ts (the Jest twin of check-contrast) |
Workflow step 1; copy to test/a11y/ |
templates/contracts/contrast.json |
The contrast contract: the app's one pair table, starting with the canonical palette's pairs; owned here and synced from the library (design-system-structure reads the same file) | Workflow step 2; copy to contracts/ |
templates/contracts/contrast.schema.json |
Its JSON schema (every key explained; synced with the contract) | With the contract |
templates/.maestro/flows/a11y/01-large-text-core-screens.yaml |
Maestro flow for core screens at the largest text size, with screenshots | Workflow step 8; copy to .maestro/flows/a11y/ |
scripts/check-a11y-code.mjs |
Checks 20 code rules over src/ (every workspace src/ too; route files are not judged as overlays; --src, --routes-dir, --min-touch, --font-cap, --screen-test; reads srcDir, routesDir and a11yGestureExceptions from rn-skills.config.json) |
Workflow step 7 |
scripts/check-contrast.mjs |
Checks the contract (unknown keys refused), that the theme palette and every colour table under src/ are declared, every declared pair in every set, every canvas surface's canvas-contrast.json in every palette set, distinct lists, --categorical lists and the a11y gates (--contract, repo-relative; reads contrastContract and canvasSurfaceDirs from rn-skills.config.json and paletteSets/surfaces from contracts/canvas-surfaces.json) |
Workflow steps 2 and 7 |
scripts/lib/wcag.mjs |
Contrast, compositing and colour-blind maths | Read only when changing a rule |
scripts/lib/load-ts-constants.mjs |
Loads a data-only TypeScript palette without packages | Read only when changing the checker |
scripts/lib/source-scan.mjs |
JSX, call and import scanning helpers (synced from the library; do not edit here) | Never by hand |
scripts/lib/object-literal.mjs |
Object-literal reader for style objects (synced from the library; do not edit here) | Never by hand |
scripts/check-lib.mjs |
Shared script helper (synced from the library; do not edit here) | Never by hand |
scripts/selftest.mjs |
Proves both checkers pass the templates plus a neutral app and catch each planted bug | After changing a script, template or fixture |
assets/shared.json |
Declares the shared files this skill copies in | When adding a shared file |
tests/ |
bases/ (a neutral Tasks app with planted findings under skills/, .agents/skills/ and a vendored SKILL.md folder that must stay silent; a chart palette and a canvas surface) and fixtures/ (one overlay suite per checker) |
When adding a rule |
Related skills
design-system-structure- the palette, tokens, text primitive andcontrast.ts; its palette tools read this contract.ui-component-library- the buttons, rows, checkboxes and dialogs these rules apply to.forms-and-input- text input names (input-label) and font scaling inTextField.i18n-strings-and-catalogs- catalog keys and ICU plurals behind every label and hint.rtl-and-direction- reading order and layout in right-to-left locales.motion-and-animation- the moment catalog, timings and reduced alternatives, loops, native stack and CSS transitions, and the check that the root mounts<MotionConfig>and starts the store.settings-and-preferences- the saved "Reduce motion" preference passed toMotionConfig.skia-canvas-rendering- canvases, theirdescribe()summary and reduced timelines.canvas-gestures-and-hit-testing- hit regions and their 44 pt test.unit-and-component-tests-renderWithProvidersand the Jest setup.e2e-maestro- the runner for the large-text flow on named devices.design-review-workflow- the end-of-build sweep across text sizes, themes and directions.quality-gates-quality-gates.jsonand its a11y budgets.