All reports
Technology by deep-research

React Native + Expo Accessibility Implementation Patterns 2026 — Component Library, Testing, and Screen Reader Support for Neurodivergent Mobile Apps

Moklabs

React Native + Expo Accessibility Implementation Patterns 2026

Product: Mellow | Date: 2026-03-20 | Tags: react-native, expo, accessibility, wcag, voiceover, talkback, neurodivergent, autism, screen-reader, testing, reanimated

Executive Summary

Building accessible React Native apps for neurodivergent users requires going beyond basic WCAG compliance. Standard accessibility documentation covers screen readers and contrast ratios, but autism-specific patterns — sensory controls, reduced motion, energy-adaptive interfaces, predictable navigation — demand a purpose-built component library. This report provides implementation-ready patterns for Expo SDK 55+: accessible base components, sensory preference management, animation controls with react-native-reanimated, font scaling, automated testing with react-native-accessibility-engine and axe DevTools Mobile, and manual testing workflows for VoiceOver/TalkBack. Every pattern targets WCAG 2.2 AA with autism-specific AAA enhancements.

1. Core Accessibility APIs in React Native

1.1 Essential Props

React Native exposes accessibility props on all core components. For neurodivergent apps, these are mandatory on every interactive element:

// Every interactive element must have ALL of these
<Pressable
  accessible={true}
  accessibilityLabel="Play pronunciation of hello"
  accessibilityHint="Double tap to play audio"
  accessibilityRole="button"
  accessibilityState={{ disabled: false, selected: false }}
  style={{ minHeight: 48, minWidth: 48 }}  // WCAG 2.5.8
>
  <Text>Play "hello"</Text>
</Pressable>

1.2 Accessibility Role Reference

RoleUse For
buttonAll tappable actions
linkNavigation to other screens
headerSection headings (VoiceOver/TalkBack navigation)
adjustableSliders, volume controls (enables swipe up/down gestures)
imageImages with descriptions
textNon-interactive text blocks
checkboxToggle sensory preferences
progressbarLesson completion, pronunciation scores
tabTab bar navigation items
tablistTab bar container

1.3 Focus Management

import { AccessibilityInfo, findNodeHandle } from 'react-native';

// Set focus to a specific element (e.g., after navigation)
const focusRef = useRef<View>(null);

useEffect(() => {
  const handle = findNodeHandle(focusRef.current);
  if (handle) {
    AccessibilityInfo.setAccessibilityFocus(handle);
  }
}, []);

// Announce dynamic content changes
AccessibilityInfo.announceForAccessibility(
  'Exercise 3 of 10 complete. Your answer was correct.'
);

1.4 Live Regions (Dynamic Content)

// For feedback that appears after exercise submission
<View
  accessibilityLiveRegion="polite"    // "polite" or "assertive"
  accessibilityRole="alert"
>
  <Text>{feedbackMessage}</Text>
</View>

Use polite for exercise feedback (waits for current announcement to finish). Use assertive for error states only.

2. Accessible Component Library

2.1 Base Button

Every button in the app should extend this base:

// components/ui/AccessibleButton.tsx
import { Pressable, Text, StyleSheet, ViewStyle } from 'react-native';
import { useSensory } from '../../contexts/SensoryContext';
import * as Haptics from 'expo-haptics';

interface Props {
  label: string;                    // ALWAYS required (visible text)
  accessibilityHint?: string;       // What happens on tap
  onPress: () => void;
  variant?: 'primary' | 'secondary' | 'ghost';
  disabled?: boolean;
  icon?: React.ReactNode;           // Icon is ALWAYS alongside text
  style?: ViewStyle;
}

export function AccessibleButton({
  label, accessibilityHint, onPress, variant = 'primary',
  disabled = false, icon, style
}: Props) {
  const { preferences } = useSensory();

  const handlePress = () => {
    if (preferences.hapticsEnabled) {
      Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
    }
    onPress();
  };

  return (
    <Pressable
      accessible={true}
      accessibilityLabel={label}
      accessibilityHint={accessibilityHint}
      accessibilityRole="button"
      accessibilityState={{ disabled }}
      onPress={handlePress}
      disabled={disabled}
      style={({ pressed }) => [
        styles.base,
        styles[variant],
        disabled && styles.disabled,
        pressed && styles.pressed,
        style,
      ]}
    >
      {icon && <>{icon}</>}
      <Text style={[
        styles.text,
        styles[`${variant}Text`],
        { fontSize: getFontSize(preferences.fontSize) }
      ]}>
        {label}
      </Text>
    </Pressable>
  );
}

const styles = StyleSheet.create({
  base: {
    minHeight: 48,
    minWidth: 48,
    paddingHorizontal: 20,
    paddingVertical: 12,
    borderRadius: 12,
    flexDirection: 'row',
    alignItems: 'center',
    justifyContent: 'center',
    gap: 8,
  },
  // ... variant styles
});

Key rules:

  • minHeight: 48, minWidth: 48 — WCAG 2.5.8 touch target
  • label is always required and always visible (no icon-only buttons)
  • Haptics respect preferences.hapticsEnabled
  • Font size respects sensory preferences
  • accessibilityLabel matches visible text

2.2 Exercise Option Card

// components/exercise/OptionCard.tsx
interface Props {
  text: string;
  index: number;
  total: number;
  selected: boolean;
  correct?: boolean;       // null until answered
  incorrect?: boolean;
  onSelect: () => void;
}

export function OptionCard({
  text, index, total, selected, correct, incorrect, onSelect
}: Props) {
  const { preferences } = useSensory();
  const isAnswered = correct !== undefined || incorrect !== undefined;

  return (
    <Pressable
      accessible={true}
      accessibilityLabel={`Option ${index + 1} of ${total}: ${text}`}
      accessibilityHint={isAnswered ? undefined : 'Double tap to select this answer'}
      accessibilityRole="button"
      accessibilityState={{
        selected,
        disabled: isAnswered,
      }}
      onPress={isAnswered ? undefined : onSelect}
      style={[
        styles.card,
        selected && styles.selected,
        correct && styles.correct,
        incorrect && styles.incorrect,
      ]}
    >
      {/* Status indicator: icon + text + color (never color alone) */}
      {correct && (
        <View style={styles.statusRow}>
          <CheckIcon />
          <Text style={styles.statusText}>Correct</Text>
        </View>
      )}
      {incorrect && (
        <View style={styles.statusRow}>
          <XIcon />
          <Text style={styles.statusText}>Incorrect</Text>
        </View>
      )}
      <Text style={[styles.optionText, {
        fontSize: getFontSize(preferences.fontSize)
      }]}>
        {text}
      </Text>
    </Pressable>
  );
}

Autism-specific:

  • Status shown via icon + text + color (triple redundancy)
  • No shake animation on incorrect answer
  • No confetti/celebration on correct answer
  • Clear “Option X of Y” for screen reader context

2.3 Progress Bar (No Streaks)

// components/ui/ProgressBar.tsx
interface Props {
  current: number;
  total: number;
  label: string;        // "Lesson progress" or "Vocabulary mastered"
}

export function ProgressBar({ current, total, label }: Props) {
  const percentage = Math.round((current / total) * 100);

  return (
    <View
      accessible={true}
      accessibilityLabel={`${label}: ${current} of ${total} (${percentage}%)`}
      accessibilityRole="progressbar"
      accessibilityValue={{
        min: 0,
        max: total,
        now: current,
        text: `${percentage}%`,
      }}
    >
      <Text style={styles.label}>{label}</Text>
      <View style={styles.track}>
        <View style={[styles.fill, { width: `${percentage}%` }]} />
      </View>
      <Text style={styles.count}>{current} / {total}</Text>
    </View>
  );
}

Key: Uses accessibilityValue with min/max/now for screen reader compatibility. Shows numeric count, not just visual bar.

2.4 Feedback Display

// components/feedback/FeedbackCard.tsx
interface FeedbackData {
  feedback: string;
  correction: { wrong: string; correct: string } | null;
  explanation: string;
  encouragement: string;
}

export function FeedbackCard({ data }: { data: FeedbackData }) {
  return (
    <View
      accessibilityLiveRegion="polite"
      accessibilityRole="alert"
      style={styles.container}
    >
      {/* Main feedback */}
      <Text
        accessible={true}
        accessibilityLabel={data.feedback}
        style={styles.feedback}
      >
        {data.feedback}
      </Text>

      {/* Side-by-side correction */}
      {data.correction && (
        <View style={styles.correctionRow}>
          <View style={styles.wrongBox}>
            <XIcon />
            <Text style={styles.wrongLabel}>You wrote</Text>
            <Text style={styles.wrongText}>{data.correction.wrong}</Text>
          </View>
          <ArrowRightIcon />
          <View style={styles.correctBox}>
            <CheckIcon />
            <Text style={styles.correctLabel}>Correct</Text>
            <Text style={styles.correctText}>{data.correction.correct}</Text>
          </View>
        </View>
      )}

      {/* Explanation (Portuguese) */}
      <Text style={styles.explanation}>{data.explanation}</Text>

      {/* Encouragement (effort-based) */}
      <Text style={styles.encouragement}>{data.encouragement}</Text>
    </View>
  );
}

3. Sensory Preference System

3.1 SensoryContext Implementation

// contexts/SensoryContext.tsx
import { createContext, useContext, useEffect, useState } from 'react';
import { useColorScheme, AccessibilityInfo } from 'react-native';
import { useReducedMotion } from 'react-native-reanimated';
import { MMKV } from 'react-native-mmkv';

const storage = new MMKV({ id: 'sensory-preferences' });

export interface SensoryPreferences {
  theme: 'light' | 'dark' | 'high-contrast';
  fontSize: 'small' | 'medium' | 'large' | 'extra-large';
  reducedMotion: boolean;
  dyslexiaFont: boolean;
  soundEnabled: boolean;
  soundVolume: number;
  musicEnabled: boolean;
  pronunciationAutoPlay: boolean;
  hapticsEnabled: boolean;
  defaultEnergyLevel: 'high' | 'medium' | 'low' | 'browse';
  breakReminders: boolean;
  breakIntervalMinutes: number;
}

const DEFAULTS: SensoryPreferences = {
  theme: 'dark',                  // Dark mode default for autism
  fontSize: 'medium',
  reducedMotion: false,           // Will be overridden by OS setting
  dyslexiaFont: false,
  soundEnabled: false,            // Sound OFF by default
  soundVolume: 50,
  musicEnabled: false,
  pronunciationAutoPlay: false,
  hapticsEnabled: false,          // Haptics OFF by default
  defaultEnergyLevel: 'medium',
  breakReminders: true,
  breakIntervalMinutes: 15,
};

interface SensoryContextValue {
  preferences: SensoryPreferences;
  update: (partial: Partial<SensoryPreferences>) => void;
  reset: () => void;
}

const SensoryContext = createContext<SensoryContextValue | null>(null);

export function SensoryProvider({ children }: { children: React.ReactNode }) {
  const systemScheme = useColorScheme();
  const osReducedMotion = useReducedMotion();

  const [preferences, setPreferences] = useState<SensoryPreferences>(() => {
    const saved = storage.getString('preferences');
    const parsed = saved ? JSON.parse(saved) : DEFAULTS;
    // Always respect OS reduced motion setting
    return { ...parsed, reducedMotion: osReducedMotion || parsed.reducedMotion };
  });

  const update = (partial: Partial<SensoryPreferences>) => {
    setPreferences(prev => {
      const next = { ...prev, ...partial };
      storage.set('preferences', JSON.stringify(next));
      return next;
    });
  };

  const reset = () => {
    const defaults = { ...DEFAULTS, reducedMotion: osReducedMotion };
    setPreferences(defaults);
    storage.set('preferences', JSON.stringify(defaults));
  };

  // Sync OS reduced motion changes
  useEffect(() => {
    if (osReducedMotion && !preferences.reducedMotion) {
      update({ reducedMotion: true });
    }
  }, [osReducedMotion]);

  return (
    <SensoryContext.Provider value={{ preferences, update, reset }}>
      {children}
    </SensoryContext.Provider>
  );
}

export function useSensory() {
  const ctx = useContext(SensoryContext);
  if (!ctx) throw new Error('useSensory must be used within SensoryProvider');
  return ctx;
}

3.2 Font Size Scale

// lib/typography.ts

const FONT_SCALE = {
  small:       { body: 14, label: 12, heading: 20, caption: 10 },
  medium:      { body: 16, label: 14, heading: 24, caption: 12 },
  large:       { body: 20, label: 16, heading: 28, caption: 14 },
  'extra-large': { body: 24, label: 20, heading: 32, caption: 16 },
} as const;

export function getFontSize(
  level: keyof typeof FONT_SCALE,
  variant: 'body' | 'label' | 'heading' | 'caption' = 'body'
): number {
  return FONT_SCALE[level][variant];
}

// Font family selection
export function getFontFamily(dyslexiaFont: boolean): string {
  return dyslexiaFont ? 'AtkinsonHyperlegible' : 'Inter';
}

3.3 Dynamic Type Support

React Native supports system font scaling via allowFontScaling and maxFontSizeMultiplier. For Mellow, we allow full system scaling but cap at 2x to prevent layout breakage:

// In app _layout.tsx or root component
import { Text, TextInput } from 'react-native';

// Set global defaults for all Text and TextInput
Text.defaultProps = {
  ...Text.defaultProps,
  allowFontScaling: true,
  maxFontSizeMultiplier: 2.0,  // Cap at 2x system scale
};

TextInput.defaultProps = {
  ...TextInput.defaultProps,
  allowFontScaling: true,
  maxFontSizeMultiplier: 2.0,
};

3.4 Theme System

// lib/themes.ts

export const themes = {
  light: {
    bg: '#F5F2EB',           // Warm off-white (not pure #FFF)
    surface: '#FFFFFF',
    surfaceAlt: '#F0EDE6',
    border: '#D4D0C8',
    text: '#1A1A1A',
    textMuted: '#6B6B6B',
    accent: '#5B6ABF',
    correct: '#2D8B4E',
    incorrect: '#C44D4D',
  },
  dark: {
    bg: '#0F0F14',
    surface: '#1A1A22',
    surfaceAlt: '#22222E',
    border: '#2E2E3E',
    text: '#E4E4E7',
    textMuted: '#71717A',
    accent: '#7C85F0',
    correct: '#4ADE80',
    incorrect: '#F87171',
  },
  'high-contrast': {
    bg: '#000000',
    surface: '#1A1A1A',
    surfaceAlt: '#2A2A2A',
    border: '#FFFFFF',
    text: '#FFFFFF',
    textMuted: '#CCCCCC',
    accent: '#FFD700',       // High-visibility gold
    correct: '#00FF7F',
    incorrect: '#FF4444',
  },
} as const;

Autism-specific choices:

  • Light theme uses warm off-white (#F5F2EB), never pure white
  • Dark theme is default (light sensitivity is common in autism)
  • High contrast option uses maximum contrast ratios (>10:1)
  • Correct/incorrect colors have >4.5:1 contrast in all themes

4. Animation and Motion Controls

4.1 Reanimated Configuration

// In app _layout.tsx
import { ReducedMotionConfig, ReduceMotion } from 'react-native-reanimated';
import { useSensory } from '../contexts/SensoryContext';

export default function RootLayout() {
  const { preferences } = useSensory();

  return (
    <ReducedMotionConfig
      mode={preferences.reducedMotion
        ? ReduceMotion.Always
        : ReduceMotion.System}
    >
      <SensoryProvider>
        <Stack />
      </SensoryProvider>
    </ReducedMotionConfig>
  );
}

4.2 Safe Animation Wrapper

// components/ui/SafeAnimated.tsx
import Animated, {
  useAnimatedStyle,
  withTiming,
  useReducedMotion,
} from 'react-native-reanimated';
import { useSensory } from '../../contexts/SensoryContext';

interface Props {
  children: React.ReactNode;
  entering?: boolean;
  style?: any;
}

// Wraps any animated component — disables animation when reduced motion is on
export function SafeFadeIn({ children, entering = true, style }: Props) {
  const osReduced = useReducedMotion();
  const { preferences } = useSensory();
  const shouldAnimate = !osReduced && !preferences.reducedMotion;

  const animatedStyle = useAnimatedStyle(() => ({
    opacity: shouldAnimate
      ? withTiming(entering ? 1 : 0, { duration: 300 })
      : entering ? 1 : 0,
  }));

  return (
    <Animated.View style={[animatedStyle, style]}>
      {children}
    </Animated.View>
  );
}

4.3 Animation Rules for Mellow

CategoryAllowedBanned
TransitionsFade (300ms ease), slide (200ms ease)Bounce, spring, elastic
FeedbackSubtle scale (1.0 → 1.02), opacity changeShake, confetti, explosion, fireworks
LoadingSimple spinner or skeletonPulsing, breathing, complex loaders
NavigationiOS-default push/popParallax, zoom, flip
SuccessCheckmark appear (fade-in)Star burst, coin shower, level-up animation
ErrorRed border + icon appearShake effect, flash, vibration pattern

All animations MUST:

  • Respect ReducedMotionConfig (instant transition when reduced motion is on)
  • Be between 200-500ms duration
  • Use ease or ease-out timing (never ease-in alone)
  • Be pausable (never auto-loop)

5. Testing Strategy

5.1 Automated Testing Stack

ToolPurposeWhen
react-native-accessibility-engineUnit test assertions on component treeEvery PR (CI)
axe DevTools Mobile LinterStatic analysis in VS CodeDuring development
axe DevTools Mobile (Appium or XCUI)Runtime automated scanningWeekly + before release
jest + @testing-library/react-nativeComponent behavior testsEvery PR (CI)

5.2 react-native-accessibility-engine in Tests

// __tests__/components/AccessibleButton.test.tsx
import { render } from '@testing-library/react-native';
import { checkA11y } from 'react-native-accessibility-engine';
import { AccessibleButton } from '../components/ui/AccessibleButton';

describe('AccessibleButton', () => {
  it('passes accessibility engine checks', () => {
    const { toJSON } = render(
      <AccessibleButton
        label="Play audio"
        onPress={() => {}}
      />
    );

    // Checks: labels, roles, touch target size, states
    expect(() => checkA11y(toJSON())).not.toThrow();
  });

  it('announces disabled state', () => {
    const { getByRole } = render(
      <AccessibleButton
        label="Submit"
        onPress={() => {}}
        disabled={true}
      />
    );

    const button = getByRole('button');
    expect(button.props.accessibilityState.disabled).toBe(true);
  });
});

5.3 Custom Accessibility Rules for Mellow

// test/a11y-rules.ts

// Rule: No icon-only buttons (autism-specific)
export const noIconOnlyButtons = {
  id: 'no-icon-only-buttons',
  matcher: (node: any) =>
    node.type === 'Pressable' || node.type === 'TouchableOpacity',
  assertion: (node: any) => {
    // Must have visible text child, not just an icon
    const hasTextChild = findTextChild(node);
    return hasTextChild;
  },
  help: {
    problem: 'Button has no visible text label',
    solution: 'Add visible text alongside the icon. Icon-only buttons are inaccessible for many neurodivergent users.',
    link: 'https://www.w3.org/WAI/WCAG22/Understanding/label-in-name.html',
  },
};

// Rule: Minimum touch target 48px (stricter than WCAG 24px)
export const touchTarget48px = {
  id: 'touch-target-48px',
  matcher: (node: any) =>
    node.props?.accessible && node.props?.accessibilityRole === 'button',
  assertion: (node: any) => {
    const style = StyleSheet.flatten(node.props?.style);
    return (style?.minHeight || 0) >= 48 && (style?.minWidth || 0) >= 48;
  },
  help: {
    problem: 'Touch target is smaller than 48px',
    solution: 'Set minHeight: 48 and minWidth: 48 on interactive elements.',
    link: 'https://developer.apple.com/design/human-interface-guidelines/accessibility',
  },
};

5.4 Manual Testing Checklist

Run this checklist before every release:

VoiceOver (iOS)

  • Every screen: swipe through all elements — each has meaningful label
  • Tab bar: each tab announces name + selected state
  • Exercise options: announce “Option X of Y: [text]”
  • Feedback card: announced automatically when it appears (live region)
  • Progress bar: announces “X of Y” value
  • Settings: each toggle announces current state
  • No unlabeled elements or “button” without context
  • Focus order matches visual top-to-bottom, left-to-right

TalkBack (Android)

  • Same checks as VoiceOver above
  • Touch exploration: all elements reachable
  • Explore by touch: no dead zones on screen
  • Volume buttons work for adjustable elements (sliders)
  • Back gesture works predictably on every screen

Sensory Controls

  • Dark mode: all text readable, no pure white backgrounds
  • High contrast: 7:1+ ratio on all text
  • Large font: layout doesn’t break at extra-large
  • Dyslexia font: all text uses Atkinson Hyperlegible
  • Reduced motion: zero animation (instant transitions)
  • Sound off: no unexpected audio at any point
  • Haptics off: no vibration at any point
  • Energy check-in: session adapts to selected level
  • Same layout on every screen (header, content, tab bar)
  • No surprise modals or popovers
  • Back button works everywhere
  • Pause/resume: close app mid-exercise, reopen, state preserved
  • No timers, countdowns, or urgency cues anywhere

6. Screen Reader Announcement Patterns

6.1 Exercise Flow Announcements

// When exercise loads
AccessibilityInfo.announceForAccessibility(
  `Exercise ${current} of ${total}. ${exercisePrompt}`
);

// When user selects answer (before submitting)
AccessibilityInfo.announceForAccessibility(
  `Selected: ${selectedOption}`
);

// When answer is submitted — correct
AccessibilityInfo.announceForAccessibility(
  `Correct. ${feedbackMessage}`
);

// When answer is submitted — incorrect
AccessibilityInfo.announceForAccessibility(
  `The correct answer is ${correctAnswer}. ${explanation}`
);

// When lesson completes
AccessibilityInfo.announceForAccessibility(
  `Lesson complete. You completed ${correct} of ${total} exercises.`
);

6.2 Navigation Announcements

// Screen transitions (in Expo Router layout)
import { usePathname } from 'expo-router';

useEffect(() => {
  const screenName = getScreenName(pathname);
  AccessibilityInfo.announceForAccessibility(`${screenName} screen`);
}, [pathname]);

7. Expo-Specific Patterns

7.1 expo-haptics Integration

import * as Haptics from 'expo-haptics';

// Always check preference before triggering
function safeHaptic(
  type: 'light' | 'medium' | 'success' | 'warning',
  enabled: boolean
) {
  if (!enabled) return;

  switch (type) {
    case 'light':
      Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
      break;
    case 'medium':
      Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
      break;
    case 'success':
      Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
      break;
    case 'warning':
      Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning);
      break;
  }
}

7.2 expo-av Audio Playback

import { Audio } from 'expo-av';

// Audio playback with preference checks
async function playPronunciation(
  audioUrl: string,
  preferences: SensoryPreferences
) {
  if (!preferences.soundEnabled) return;
  if (!preferences.pronunciationAutoPlay) return; // User must tap to play

  const { sound } = await Audio.Sound.createAsync(
    { uri: audioUrl },
    { volume: preferences.soundVolume / 100 }
  );

  await sound.playAsync();

  // Announce for screen reader
  AccessibilityInfo.announceForAccessibility('Playing pronunciation audio');
}

7.3 Font Loading (Inter + Atkinson Hyperlegible)

// In app _layout.tsx
import { useFonts } from 'expo-font';

export default function RootLayout() {
  const [fontsLoaded] = useFonts({
    'Inter': require('../assets/fonts/Inter-Regular.ttf'),
    'Inter-Medium': require('../assets/fonts/Inter-Medium.ttf'),
    'Inter-SemiBold': require('../assets/fonts/Inter-SemiBold.ttf'),
    'Inter-Bold': require('../assets/fonts/Inter-Bold.ttf'),
    'AtkinsonHyperlegible': require('../assets/fonts/AtkinsonHyperlegible-Regular.ttf'),
    'AtkinsonHyperlegible-Bold': require('../assets/fonts/AtkinsonHyperlegible-Bold.ttf'),
  });

  if (!fontsLoaded) return <SplashScreen />;

  return (
    <SensoryProvider>
      <ReducedMotionConfig mode={ReduceMotion.System}>
        <Stack />
      </ReducedMotionConfig>
    </SensoryProvider>
  );
}

8. CI/CD Integration

8.1 Accessibility Checks in CI

# .github/workflows/a11y.yml
name: Accessibility Checks

on: [pull_request]

jobs:
  a11y:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
      - run: npm ci
      - run: npm run test:a11y    # runs accessibility engine + custom rules
      - run: npm run lint:a11y    # runs axe linter rules

8.2 Pre-commit Hook

// package.json
{
  "scripts": {
    "test:a11y": "jest --testPathPattern='a11y|accessibility'",
    "lint:a11y": "npx @axe-core/react-native-linter ."
  }
}

9. Recommendations for Mellow Implementation

Must-Have (MVP)

  1. SensoryContext provider wrapping entire app with MMKV persistence
  2. AccessibleButton base component used everywhere (48px targets, visible labels)
  3. ReducedMotionConfig with ReduceMotion.System in root layout
  4. Dark mode as default theme
  5. Font size scaling (4 levels) with system Dynamic Type support
  6. Screen reader labels on all interactive elements
  7. Live region announcements on feedback cards
  8. react-native-accessibility-engine in CI
  9. Manual VoiceOver + TalkBack testing before each release

Should-Have (V1.1)

  1. Axe DevTools Mobile automated scanning in weekly CI
  2. Atkinson Hyperlegible font toggle
  3. High-contrast theme
  4. Custom accessibility rules (icon-only detection, 48px enforcement)
  5. Accessibility snapshot testing (Callstack method)

Nice-to-Have (V2)

  1. Accessibility API usage analytics (which settings users enable)
  2. Focus management optimization (custom focus order per screen)
  3. Switch control support (for users with motor impairments)
  4. Voice control navigation (iOS Voice Control support)

Sources

Related Reports