s at build time. Decouple visual styling from accessibility traits.
// src/config/accessibility.ts
export const AccessibilityTokens = {
minContrastRatio: 4.5,
dynamicTypeScale: {
small: 0.875,
medium: 1.0,
large: 1.25,
extraLarge: 1.5,
},
focusOrderStrategy: 'visual-to-dom' as const,
screenReaderTimeout: 1500,
} as const;
export type AccessibilityContract = {
role: string;
label: string;
hint?: string;
isImportantForAccessibility?: boolean;
accessibilityElementsHidden?: boolean;
};
Step 2: Build Semantic Wrapper Components
Never rely on raw View or Text for interactive elements. Create typed wrappers that enforce accessibility contracts and prevent trait leakage.
// src/components/AccessibleButton.tsx
import React from 'react';
import { TouchableOpacity, Text, StyleSheet } from 'react-native';
import { AccessibilityContract } from '../config/accessibility';
interface AccessibleButtonProps extends AccessibilityContract {
onPress: () => void;
children: React.ReactNode;
}
export const AccessibleButton: React.FC<AccessibleButtonProps> = ({
role,
label,
hint,
isImportantForAccessibility = true,
onPress,
children,
}) => {
return (
<TouchableOpacity
style={styles.button}
onPress={onPress}
accessibilityRole={role}
accessibilityLabel={label}
accessibilityHint={hint}
importantForAccessibility={isImportantForAccessibility ? 'yes' : 'no-hide-descendants'}
focusable={true}
>
{children}
</TouchableOpacity>
);
};
const styles = StyleSheet.create({
button: {
padding: 16,
borderRadius: 8,
backgroundColor: '#0055FF',
},
});
Step 3: Implement Dynamic Type Scaling
Hardcoded font sizes break accessibility. Use platform-provided scaling factors and relative units.
// src/utils/dynamicType.ts
import { Dimensions, Platform } from 'react-native';
export const getScaleFactor = (): number => {
const fontSizeMultiplier = Platform.OS === 'ios'
? require('react-native').Text.defaultProps?.style?.fontSize ?? 1
: 1;
return Dimensions.get('window').fontScale || 1.0;
};
export const scaleFontSize = (baseSize: number): number => {
const scale = getScaleFactor();
const maxScale = 1.5;
return Math.min(baseSize * Math.max(scale, 1), baseSize * maxScale);
};
Step 4: Wire Focus Management and Modal Trapping
Screen readers rely on explicit focus order. Modals must trap focus and restore it on dismissal.
// src/hooks/useFocusTrap.ts
import { useRef, useEffect } from 'react';
import { AccessibilityInfo, findNodeHandle } from 'react-native';
export const useFocusTrap = (isActive: boolean) => {
const containerRef = useRef(null);
useEffect(() => {
if (isActive && containerRef.current) {
const node = findNodeHandle(containerRef.current);
if (node) {
AccessibilityInfo.setAccessibilityFocus(node);
}
}
}, [isActive]);
return containerRef;
};
Step 5: Integrate Automated Validation
Accessibility cannot be manually verified at scale. Wire linting and testing into CI/CD.
// eslint.config.js
import a11y from 'eslint-plugin-jsx-a11y';
export default [
{
files: ['**/*.{ts,tsx}'],
plugins: { a11y },
rules: {
'a11y/accessible-emoji': 'warn',
'a11y/no-access-key': 'error',
'a11y/role-has-required-aria-props': 'error',
},
},
];
Architecture rationale: By centralizing accessibility tokens, enforcing semantic wrappers, and automating validation, you eliminate trait drift. The component API becomes the contract. Developers cannot accidentally omit labels or break focus order without triggering build-time or test-time failures. This reduces cognitive load and ensures consistency across teams.
Pitfall Guide
-
Hardcoding contrast ratios without theme awareness
Contrast checks must account for dynamic themes and OS-level dark mode. Static values fail when users override system colors. Always compute contrast at runtime using the active theme tokens.
-
Overriding native components without bridging accessibility traits
Custom View hierarchies that replace Button, Switch, or TextInput strip platform-native accessibility behavior. If you must build custom UI, explicitly map accessibilityRole, accessibilityState, and accessibilityValue.
-
Ignoring accessibilityElementsHidden on decorative elements
Decorative icons, spacers, and background patterns pollute screen reader output. Mark them with importantForAccessibility="no-hide-descendants" to prevent vocalization and focus trapping.
-
Misusing accessibilityLabel as visual text
accessibilityLabel should describe purpose, not duplicate on-screen text. If a button says "Submit", the label should be "Submit form" or "Save changes". Redundant labels cause screen readers to read twice.
-
Assuming screen readers follow visual DOM order
VoiceOver and TalkBack traverse the accessibility tree, not the visual layout. If your UI uses absolute positioning or flex reordering, explicitly set accessibilityViewIsModal or reorder elements in the JSX tree to match logical flow.
-
Neglecting focus management in overlays
Modals, drawers, and bottom sheets that don't trap focus allow screen readers to interact with background content. Implement focus trapping and restore focus to the trigger element on dismissal.
-
Treating accessibility as a pre-release audit
Accessibility bugs compound. Fixing them after UI is complete requires refactoring state, retesting navigation, and delaying releases. Treat accessibility as a CI gate, not a QA checklist.
Best practices from production: Use platform-native components whenever possible. Validate with VoiceOver/TalkBack during daily development, not just before launch. Document accessibility contracts in component TypeScript interfaces. Run automated contrast and label checks in PR pipelines. Involve users with disabilities in beta testing; automated tools catch 60% of issues, human validation catches the rest.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Startup MVP | Semantic wrappers + automated linting | Speed with baseline compliance; prevents debt accumulation | Low (+5% dev time) |
| Enterprise app | Full token system + focus management + CI gates | Regulatory compliance, multi-team consistency, audit readiness | Medium (+10% dev time) |
| Legacy retrofit | Incremental trait mapping + contrast audit + focus patches | Minimizes refactoring risk while addressing critical violations | High (2β3x sprint cost) |
| Cross-platform (RN/Flutter) | Platform-agnostic accessibility layer + native bridges | Ensures consistent behavior across iOS/Android without duplicating logic | Medium (+8% architecture overhead) |
| High-interaction gaming | Selective trait mapping + haptic/audio feedback fallbacks | Screen readers are secondary; focus on input accessibility and motion reduction | Low (targeted patches) |
Configuration Template
// src/config/accessibility.config.ts
import { Platform } from 'react-native';
export const ACCESSIBILITY_CONFIG = {
ios: {
reduceMotion: Platform.OS === 'ios' ? require('react-native').AccessibilityInfo.isReduceMotionEnabled : false,
voiceOverEnabled: false,
maxFontSizeMultiplier: 1.5,
},
android: {
talkBackEnabled: false,
maxFontSizeMultiplier: 1.5,
contrastEnhancement: false,
},
validation: {
minContrastRatio: 4.5,
requireAccessibilityLabel: true,
blockInteractiveWithoutRole: true,
},
testing: {
jestTimeout: 3000,
screenshotDiffThreshold: 0.02,
},
};
export type AccessibilityConfig = typeof ACCESSIBILITY_CONFIG;
// jest.setup.js
import '@testing-library/jest-native/extend-expect';
import { AccessibilityInfo } from 'react-native';
// Mock accessibility state for consistent tests
jest.mock('react-native', () => {
const RN = jest.requireActual('react-native');
return {
...RN,
AccessibilityInfo: {
...RN.AccessibilityInfo,
isReduceMotionEnabled: jest.fn(() => Promise.resolve(false)),
fetch: jest.fn(() => Promise.resolve(false)),
},
};
});
Quick Start Guide
- Initialize accessibility tokens: Copy
ACCESSIBILITY_CONFIG into your project root and import it into your theme provider.
- Install validation tooling: Run
npm i -D eslint-plugin-jsx-a11y @testing-library/react-native @testing-library/jest-native and apply the ESLint config.
- Replace interactive primitives: Swap
TouchableOpacity and Pressable with the AccessibleButton wrapper. Add accessibilityRole and accessibilityLabel to every interactive element.
- Add CI gate: Create a GitHub Actions or GitLab CI step that runs
npx eslint src/ and npm test -- --coverage. Block merges if accessibility rules fail.
- Validate with assistive tech: Enable VoiceOver (iOS) or TalkBack (Android) on a test device. Navigate your primary flow. Fix focus order and label issues before proceeding to feature development.