Accessibility¶
Accessibility is part of "is this on-brand?" — never a separate workstream. This page documents the commitments we hold every feature to.
Contrast¶
We target WCAG AA at minimum for all text and interactive elements. Our actual palette comfortably exceeds AA for body text and clears AAA for primary content.
| Combination | Mode | Ratio | WCAG |
|---|---|---|---|
text (#FAFAFA) on background (#0e0e0f) |
dark | ~19.4:1 | AAA |
text (#FAFAFA) on surface (#18181a) |
dark | ~16.2:1 | AAA |
textSecondary (#A1A1AA) on surface |
dark | ~7.2:1 | AAA |
textMuted (#52525B) on surface |
dark | ~3.6:1 | AA (large text only) |
primary (#D4955A) on background |
dark | ~6.5:1 | AAA |
text (#18181B) on background (#FFFFFF) |
light | ~16.8:1 | AAA |
text (#18181B) on surfaceContainerLow (#F4F4F5) |
light | ~15.4:1 | AAA |
textSecondary (#71717A) on background |
light | ~5.1:1 | AA |
textMuted (#A1A1AA) on background |
light | ~2.6:1 | fails AA — large text only |
primary (#d6811e) on background |
light | ~3.9:1 | AA (large text only) |
Two muted combinations are AA-only for large text
textMuted in light mode and primary in light mode both fall under the 4.5:1 threshold for normal-sized body text. Use either only on labels at 18pt+ or 14pt+ bold (the WCAG large-text threshold). Don't use textMuted for body paragraphs in light mode; use textSecondary instead.
When you add a new color combination, run it through WebAIM Contrast Checker before shipping.
Focus indicators¶
Every interactive element shows a clear focus state. The default Material focus ring is heavy and inconsistent with the no-line rule; we replace it with a tonal shift for surface elements and a ghost outline for inline interactive elements.
| Element | Focus treatment |
|---|---|
| Button | Background shifts to the next-higher surface tier; primary buttons gain a 2px border (15% opacity) ring |
| Input | Background shifts to surfaceContainerHighest; label color changes to primary |
| Link (inline text) | Underline appears; color stays the same |
| Card / tappable surface | 2px border (15% opacity) ring on the outer edge |
Never outline: none. If the default browser ring is suppressed, replace it with an equally visible alternative.
Tap targets¶
The minimum interactive area is 44 × 44 pt (Apple HIG / iOS) / 48 × 48 dp (Material Design). On dense surfaces (settings rows, list items), use hitSlop to extend the touch zone past the visual element rather than padding the visual itself.
Spacing between adjacent tap targets is at least sm (8px) — closer than that and mistaps become common, especially on mobile.
Screen readers¶
Required props¶
- Every
<Image>has anaccessibilityLabeldescribing the content (not "image" — the actual subject) oraccessibilityElementsHidden={true}if purely decorative. - Every interactive element has either visible text or an
accessibilityLabel. - Every input has an
accessibilityLabeleven if a visible label exists — the label-element association isn't automatic in React Native.
Hierarchy¶
Use semantic roles, not just accessibilityLabel strings.
<Pressable
accessibilityRole="button"
accessibilityLabel="Send message"
accessibilityHint="Sends your message to the chat"
>
accessibilityHint is optional — only include when the action's effect isn't obvious from the label.
Dynamic content¶
Live regions (chat messages arriving, toasts appearing) use accessibilityLiveRegion="polite" on Android / accessibilityLiveRegion="assertive" only for errors. On iOS, fire AccessibilityInfo.announceForAccessibility(text) for one-shot announcements.
Color is not the only signal¶
Never use color alone to convey state.
| Bad | Good |
|---|---|
| Required fields shown in red | Required fields shown in red plus an asterisk in the label |
| Active tab is gold | Active tab is gold plus has bold text plus has an underline indicator |
| Error toast in red | Error toast in red plus has an error icon plus has the word "Error" in the title |
This matters for colorblind users (~8% of male users globally) and for high-contrast / monochrome OS modes.
Motion preferences¶
Honor prefers-reduced-motion (web) and AccessibilityInfo.isReduceMotionEnabled() (native). When set, disable all non-functional animation. See Motion.
Text size scaling¶
Respect the OS-level dynamic type setting. Use Dimensions.scale and React Native's allowFontScaling (default true) — don't pass allowFontScaling={false} to opt out unless there's an unavoidable layout reason.
In light mode the textMuted contrast issue documented above is exacerbated at small sizes; the WCAG large-text exemption becomes more important when users scale up.
Internationalization considerations¶
The product ships in four locales (en-US, ja-JP, zh-CN, zh-TW). Several locale-specific accessibility points:
- Japanese / Chinese characters need more vertical space than Latin — use line-height
1.5or higher in body text, never below1.3. - Right-to-left languages are not supported today. If ever added, all icons that imply direction (back arrows, forward chevrons) will need flipped variants.
- Avoid contractions and idioms in UI copy that need to translate cleanly. "Can't connect" translates fine; "Hit the road" doesn't.
See Localization for the i18next wiring.
Testing¶
There's no automated a11y CI gate today. When shipping non-trivial UI, run through:
- Keyboard navigation (web) — Tab through every interactive element. Order should match visual order. Focus rings visible.
- VoiceOver (iOS) / TalkBack (Android) — Read through the screen with the screen reader on. Labels make sense in isolation.
- Reduce-motion — Toggle on in OS settings. Animations should degrade gracefully.
- High-contrast mode — Toggle on in OS settings. Verify nothing relies on subtle color shifts that disappear.
- Text scaling — Set OS text size to maximum. Layout shouldn't break catastrophically.
If a flow fails any of these, it's not done.