mobile-accessibility
Mobile accessibility patterns for iOS. Use when implementing VoiceOver support, Dynamic Type, keyboard handling, or ensuring WCAG compliance.
- Category: discovered
- Tags: github, community
- Version: 1.0.0
- Author: walid
- Downloads: 0
- Updated: 2025-11-22
Download the latest version (v1.0.0) from this page.
SKILL.md
---
name: mobile-accessibility
description: Mobile accessibility patterns for iOS. Use when implementing VoiceOver support, Dynamic Type, keyboard handling, or ensuring WCAG compliance.
---
# Mobile Accessibility
This skill covers accessibility best practices for iOS apps, ensuring GalliGo Mobile is usable by everyone.
## When to Use This Skill
- Adding VoiceOver labels to interactive elements
- Supporting Dynamic Type (text scaling)
- Ensuring proper keyboard handling
- Meeting WCAG AA compliance
- Testing with accessibility tools
## VoiceOver Support
VoiceOver is iOS's screen reader. All interactive elements need proper labels.
### Basic VoiceOver Properties
```typescript
<Pressable
accessible={true}
accessibilityLabel="Add to favorites"
accessibilityHint="Double tap to save this place to your favorites"
accessibilityRole="button"
onPress={handleFavorite}
>
<HeartIcon />
</Pressable>
```
**Properties**:
- `accessible`: Enable accessibility for this element (defaults to `true` for interactive elements)
- `accessibilityLabel`: What the element is (e.g., "Share button")
- `accessibilityHint`: What the element does (e.g., "Opens share sheet")
- `accessibilityRole`: Type of element (button, link, header, etc.)
### Accessibility Roles
```typescript
// Button
<Pressable accessibilityRole="button">
<Text>Save</Text>
</Pressable>
// Link
<Pressable accessibilityRole="link">
<Text className="text-blue-500">Learn More</Text>
</Pressable>
// Header
<Text accessibilityRole="header" className="text-2xl font-bold">
Section Title
</Text>
// Image
<Image
source={{ uri: imageUrl }}
accessibilityRole="image"
accessibilityLabel="Photo of Golden Gate Bridge"
/>
// Tab (for tab bars)
<Pressable accessibilityRole="tab" accessibilityState={{ selected: isActive }}>
<HomeIcon />
<Text>Home</Text>
</Pressable>
// Switch/Toggle
<Switch
accessibilityRole="switch"
accessibilityState={{ checked: isEnabled }}
/>
```
### Accessibility States
Communicate current state to VoiceOver:
```typescript
// Selected state (tabs, list items)
<Pressable
accessibilityRole="button"
accessibilityState={{ selected: isSelected }}
>
<Text>Option 1</Text>
</Pressable>
// Disabled state
<Pressable
disabled={isDisabled}
accessibilityState={{ disabled: isDisabled }}
>
<Text>Submit</Text>
</Pressable>
// Checked state (checkboxes, switches)
<Pressable
accessibilityRole="checkbox"
accessibilityState={{ checked: isChecked }}
>
<CheckboxIcon checked={isChecked} />
<Text>Accept terms</Text>
</Pressable>
// Busy/Loading state
<View accessibilityState={{ busy: isLoading }}>
{isLoading ? <ActivityIndicator /> : <Content />}
</View>
// Expanded state (accordions, dropdowns)
<Pressable
accessibilityRole="button"
accessibilityState={{ expanded: isExpanded }}
>
<Text>More options</Text>
<ChevronIcon />
</Pressable>
```
### Images and Icons
#### Decorative Images
Hide from VoiceOver:
```typescript
<View accessible={false}>
<LinearGradient /> {/* Purely decorative */}
</View>
<Image
source={decorativePattern}
accessibilityElementsHidden={true}
/>
```
#### Informative Images
Provide descriptions:
```typescript
<Image
source={{ uri: place.imageUrl }}
accessibilityLabel={`Photo of ${place.name}`}
accessibilityRole="image"
/>
<Image
source={userAvatar}
accessibilityLabel={`${user.name}'s profile picture`}
/>
```
#### Icon Buttons
Label the action, not the icon:
```typescript
// ❌ BAD: Describes icon appearance
<Pressable accessibilityLabel="Heart icon">
<HeartIcon />
</Pressable>
// ✅ GOOD: Describes action
<Pressable
accessibilityLabel="Add to favorites"
accessibilityHint="Double tap to save this place"
accessibilityRole="button"
>
<HeartIcon />
</Pressable>
```
### Complex Components
Group related elements:
```typescript
// Card with multiple elements
<View
accessible={true}
accessibilityLabel={`${place.name}, ${place.category}, ${place.rating} stars`}
accessibilityRole="button"
accessibilityHint="Double tap to view details"
>
<Image source={{ uri: place.imageUrl }} accessibilityElementsHidden={true} />
<Text accessibilityElementsHidden={true}>{place.name}</Text>
<Text accessibilityElementsHidden={true}>{place.category}</Text>
<Text accessibilityElementsHidden={true}>{place.rating} ★</Text>
</View>
```
**Pattern**: Set `accessible={true}` on container with combined label, then hide child elements with `accessibilityElementsHidden={true}`.
### Live Regions
Announce dynamic content changes:
```typescript
<View
accessibilityLiveRegion="polite" // or "assertive"
accessibilityLabel={statusMessage}
>
<Text>{statusMessage}</Text>
</View>
```
**Usage**:
- `polite`: Announce when user finishes current action (most common)
- `assertive`: Interrupt and announce immediately (use sparingly)
**Example**:
```typescript
const [toast, setToast] = useState('')
// When toast appears, VoiceOver announces it
<View accessibilityLiveRegion="polite">
{toast && <Text>{toast}</Text>}
</View>
```
## Dynamic Type Support
Allow text to scale with user's preferred text size.
### Font Scaling
React Native automatically scales fonts, but ensure layouts adapt:
```typescript
// ✅ GOOD: Flexible layout
<View className="p-4">
<Text className="text-lg font-semibold">
This text will scale
</Text>
</View>
// ❌ BAD: Fixed height that breaks with large text
<View className="h-12"> {/* Fixed height */}
<Text className="text-lg">Text might truncate</Text>
</View>
```
### Testing Different Text Sizes
```typescript
import { PixelRatio } from 'react-native'
// Check current font scale
const fontScale = PixelRatio.getFontScale()
console.log(`Current font scale: ${fontScale}`) // 1.0 = normal, 1.5 = large
```
**Test on device**:
Settings → Accessibility → Display & Text Size → Larger Text
### Handling Large Text
```typescript
// Use numberOfLines for truncation
<Text numberOfLines={2} className="text-base">
{longText}
</Text>
// Or allow wrapping
<Text className="text-base flex-wrap">
{longText}
</Text>
// Adjust layout for large text
import { useWindowDimensions } from 'react-native'
const Component = () => {
const fontScale = PixelRatio.getFontScale()
const isLargeText = fontScale > 1.3
return (
<View className={isLargeText ? 'flex-col' : 'flex-row'}>
{/* Layout changes for large text */}
</View>
)
}
```
## Contrast Requirements
WCAG AA standards require minimum contrast ratios.
### Contrast Ratios
- **4.5:1** minimum for normal text (< 18pt)
- **3:1** minimum for large text (≥ 18pt or 14pt bold)
- **3:1** minimum for UI components and graphics
### Color Combinations
```typescript
// ✅ GOOD: High contrast
<Text className="text-gray-900 bg-white"> // ~21:1 ratio
High contrast text
</Text>
<Text className="text-white bg-blue-600"> // ~4.5:1 ratio
Readable on blue
</Text>
// ❌ BAD: Low contrast
<Text className="text-gray-400 bg-white"> // ~2.7:1 - fails WCAG AA
Hard to read
</Text>
<Text className="text-yellow-300 bg-white"> // ~1.4:1 - fails
Very hard to read
</Text>
```
### Testing Contrast
Use online tools:
- WebAIM Contrast Checker: https://webaim.org/resources/contrastchecker/
- Chrome DevTools: Built-in contrast checker
### Don't Rely on Color Alone
```typescript
// ❌ BAD: Only color indicates status
<Text className={isError ? 'text-red-500' : 'text-green-500'}>
{message}
</Text>
// ✅ GOOD: Icon + color
<View className="flex-row items-center">
{isError ? <ErrorIcon /> : <SuccessIcon />}
<Text className={isError ? 'text-red-500' : 'text-green-500'}>
{message}
</Text>
</View>
```
## Touch Targets
Minimum 44×44pt for all interactive elements (iOS HIG requirement).
```typescript
// ✅ GOOD: 44pt minimum
<Pressable className="w-11 h-11 items-center justify-center">
<Icon size={24} />
</Pressable>
// ✅ GOOD: Padding creates 44pt target
<Pressable className="p-3"> {/* 24 + (12 × 2) = 48pt */}
<Icon size={24} />
</Pressable>
// ❌ BAD: Only 32pt
<Pressable className="w-8 h-8">
<Icon size={24} />
</Pressable>
```
See @ios-design-guidelines for more touch target info.
## Keyboard Handling
Ensure forms are keyboard-accessible.
### KeyboardAvoidingView
```typescript
import { KeyboardAvoidingView, Platform } from 'react-native'
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
className="flex-1"
>
<ScrollView>
<TextInput placeholder="Email" />
<TextInput placeholder="Password" secureTextEntry />
</ScrollView>
</KeyboardAvoidingView>
```
### Dismiss Keyboard on Tap
```typescript
import { TouchableWithoutFeedback, Keyboard } from 'react-native'
<TouchableWithoutFeedback onPress={Keyboard.dismiss}>
<View className="flex-1">
<TextInput placeholder="Search" />
{/* Rest of content */}
</View>
</TouchableWithoutFeedback>
```
### Keyboard Dismiss Mode
```typescript
<ScrollView keyboardDismissMode="on-drag">
{/* Keyboard dismisses when user scrolls */}
<TextInput />
</ScrollView>
<FlatList
data={items}
renderItem={renderItem}
keyboardDismissMode="on-drag"
keyboardShouldPersistTaps="handled" // Allow tapping list items
/>
```
### Input Accessibility
```typescript
<TextInput
placeholder="Email address"
accessibilityLabel="Email address"
accessibilityHint="Enter your email to sign in"
autoComplete="email"
keyboardType="email-address"
textContentType="emailAddress"
/>
<TextInput
placeholder="Password"
accessibilityLabel="Password"
secureTextEntry
autoComplete="password"
textContentType="password"
/>
```
## Focus Management
### Auto-Focus on Screen Load
```typescript
import { useRef, useEffect } from 'react'
import { TextInput } from 'react-native'
const SearchScreen = () => {
const inputRef = useRef<TextInput>(null)
useEffect(() => {
// Focus input when screen loads
inputRef.current?.focus()
}, [])
return (
<TextInput
ref={inputRef}
placeholder="Search places"
/>
)
}
```
### Focus Next Input on Submit
```typescript
const LoginForm = () => {
const passwordRef = useRef<TextInput>(null)
return (
<View>
<TextInput
placeholder="Email"
returnKeyType="next"
onSubmitEditing={() => passwordRef.current?.focus()}
/>
<TextInput
ref={passwordRef}
placeholder="Password"
returnKeyType="done"
onSubmitEditing={handleLogin}
/>
</View>
)
}
```
## Reduced Motion
Some users enable "Reduce Motion" in accessibility settings. Respect this preference:
```typescript
import { AccessibilityInfo } from 'react-native'
import { useEffect, useState } from 'react'
const Component = () => {
const [reduceMotion, setReduceMotion] = useState(false)
useEffect(() => {
AccessibilityInfo.isReduceMotionEnabled().then(setReduceMotion)
const subscription = AccessibilityInfo.addEventListener(
'reduceMotionChanged',
setReduceMotion
)
return () => subscription.remove()
}, [])
return (
<Animated.View
entering={reduceMotion ? undefined : FadeIn}
exiting={reduceMotion ? undefined : FadeOut}
>
{/* Content */}
</Animated.View>
)
}
```
## Testing with VoiceOver
### Enable VoiceOver
**Shortcut**: Triple-click side button (or home button)
**Settings**: Settings → Accessibility → VoiceOver
### VoiceOver Gestures
- **Single tap**: Select element (VoiceOver reads it)
- **Double tap**: Activate selected element
- **Swipe right/left**: Navigate to next/previous element
- **Two-finger swipe up**: Read from current position
- **Three-finger swipe left/right**: Scroll
### Testing Checklist
1. Enable VoiceOver
2. Navigate through each screen
3. Verify all interactive elements are:
- Selectable (VoiceOver highlights them)
- Have clear labels
- Can be activated (double tap works)
4. Test forms (can you complete them?)
5. Test navigation (can you go back?)
6. Verify images have alt text or are hidden
## Common Accessibility Patterns
### List Item
```typescript
<Pressable
accessible={true}
accessibilityRole="button"
accessibilityLabel={`${place.name}, ${place.category}, ${place.distance} away`}
accessibilityHint="Double tap to view details"
onPress={() => navigation.navigate('PlaceDetails', { placeId: place.id })}
>
<View className="flex-row p-4">
<Image source={{ uri: place.imageUrl }} accessibilityElementsHidden={true} />
<View className="ml-3 flex-1">
<Text accessibilityElementsHidden={true}>{place.name}</Text>
<Text accessibilityElementsHidden={true}>{place.category}</Text>
</View>
</View>
</Pressable>
```
### Button with Icon
```typescript
<Pressable
accessibilityRole="button"
accessibilityLabel="Share place"
accessibilityHint="Opens share sheet to send this place to friends"
onPress={handleShare}
className="p-3"
>
<ShareIcon />
</Pressable>
```
### Toggle/Switch
```typescript
<View className="flex-row items-center justify-between p-4">
<Text>Enable notifications</Text>
<Switch
value={isEnabled}
onValueChange={setIsEnabled}
accessibilityRole="switch"
accessibilityLabel="Enable notifications"
accessibilityState={{ checked: isEnabled }}
/>
</View>
```
### Modal
```typescript
<Modal
visible={isVisible}
onRequestClose={handleClose}
accessibilityViewIsModal={true} // Focus only on modal content
>
<View className="flex-1 bg-white">
<Pressable
accessibilityRole="button"
accessibilityLabel="Close modal"
onPress={handleClose}
>
<CloseIcon />
</Pressable>
{/* Modal content */}
</View>
</Modal>
```
## Accessibility Checklist
Use this checklist when building components:
- [ ] All interactive elements have `accessibilityLabel`
- [ ] Icons have descriptive labels (action, not appearance)
- [ ] Images have alt text or are marked decorative
- [ ] Touch targets are ≥ 44×44pt
- [ ] Text contrast meets WCAG AA (4.5:1 for normal text)
- [ ] Forms work with keyboard (KeyboardAvoidingView)
- [ ] Text scales with Dynamic Type
- [ ] Tested with VoiceOver enabled
- [ ] Loading states announced (accessibilityLiveRegion)
- [ ] Errors clearly communicated (not just color)
## Related Skills
- @ios-design-guidelines - Touch targets and iOS standards
- @react-native-patterns - Accessible component patterns
---
Accessibility is not optional. Build for everyone from the start, not as an afterthought. Test with VoiceOver regularly.