Skip to content

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 an accessibilityLabel describing the content (not "image" — the actual subject) or accessibilityElementsHidden={true} if purely decorative.
  • Every interactive element has either visible text or an accessibilityLabel.
  • Every input has an accessibilityLabel even 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.5 or higher in body text, never below 1.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:

  1. Keyboard navigation (web) — Tab through every interactive element. Order should match visual order. Focus rings visible.
  2. VoiceOver (iOS) / TalkBack (Android) — Read through the screen with the screen reader on. Labels make sense in isolation.
  3. Reduce-motion — Toggle on in OS settings. Animations should degrade gracefully.
  4. High-contrast mode — Toggle on in OS settings. Verify nothing relies on subtle color shifts that disappear.
  5. Text scaling — Set OS text size to maximum. Layout shouldn't break catastrophically.

If a flow fails any of these, it's not done.