15% of the world population (1.3 billion people) has some form of disability. Accessible apps reach more users, improve app store rankings, and may be legally required. This comprehensive guide covers implementation for iOS and Android.
Why Accessibility Matters
- 1.3 billion people have disabilities (WHO)
- 97% of websites/apps fail basic accessibility
- Legal requirement in many countries (ADA, EAA)
- Better UX for everyone (situational disabilities)
- App Store favors accessible apps
- 15% larger addressable market
WCAG 2.1 Principles (POUR)
Perceivable
Information must be presentable to users in ways they can perceive.
- Text alternatives for non-text content
- Captions for audio/video
- Adaptable content structure
- Sufficient color contrast
Operable
UI components must be operable by all users.
- Keyboard/screen reader accessible
- Adequate time to interact
- No seizure-inducing content
- Clear navigation
Understandable
Information and operation must be understandable.
- Readable text
- Predictable behavior
- Input assistance
- Error prevention and correction
Robust
Content must work with assistive technologies.
- Compatible with screen readers
- Semantic markup
- Proper accessibility labels
iOS Accessibility (VoiceOver)
Basic Implementation
// Enable accessibility
button.isAccessibilityElement = true
button.accessibilityLabel = "Add to cart"
button.accessibilityHint = "Adds this item to your shopping cart"
button.accessibilityTraits = .button
// Image
imageView.isAccessibilityElement = true
imageView.accessibilityLabel = "Product photo of blue t-shirt"
// Decorative images (hide from VoiceOver)
decorativeImage.isAccessibilityElement = false
// Custom elements
customView.isAccessibilityElement = true
customView.accessibilityLabel = "Progress: 75%"
customView.accessibilityTraits = .updatesFrequently
Accessibility Traits
Common traits:
.button // Tappable button
.link // Opens URL/navigation
.header // Section header
.adjustable // Can be incremented/decremented
.selected // Currently selected
.staticText // Non-interactive text
.image // Image element
.playsSound // Plays audio
.updatesFrequently // Dynamic content
// Combine traits
element.accessibilityTraits = [.button, .selected]
Grouping Elements
// Group related elements
let containerView = UIView()
containerView.isAccessibilityElement = true
containerView.accessibilityLabel = "John Doe, Software Engineer, Online"
containerView.accessibilityTraits = .button
containerView.accessibilityHint = "Opens profile"
// Alternative: Custom accessibility container
class CustomCell: UITableViewCell {
override var accessibilityElements: [Any]? {
get {
return [nameLabel, roleLabel, statusIndicator]
}
set {}
}
}
Dynamic Content
// Notify VoiceOver of changes
UIAccessibility.post(notification: .screenChanged, argument: newView)
// Announce message
UIAccessibility.post(notification: .announcement, argument: "Item added to cart")
// Layout changed
UIAccessibility.post(notification: .layoutChanged, argument: firstElement)
Custom Actions
// Add custom actions to elements
let deleteAction = UIAccessibilityCustomAction(
name: "Delete",
target: self,
selector: #selector(deleteItem)
)
let editAction = UIAccessibilityCustomAction(
name: "Edit",
target: self,
selector: #selector(editItem)
)
cell.accessibilityCustomActions = [deleteAction, editAction]
@objc func deleteItem() -> Bool {
// Perform delete
UIAccessibility.post(notification: .announcement, argument: "Item deleted")
return true
}
Android Accessibility (TalkBack)
Basic Implementation
// XML
// Kotlin
button.contentDescription = "Add to cart"
// Group elements
containerView.contentDescription = "John Doe, Software Engineer, Online"
// Heading
textView.accessibilityHeading = true
Live Regions
Live region modes:
- none: No announcement (default)
- polite: Announce when user is idle
- assertive: Interrupt and announce immediately
Custom Actions
ViewCompat.addAccessibilityAction(view, "Delete") { view, arguments ->
deleteItem()
true
}
ViewCompat.addAccessibilityAction(view, "Edit") { view, arguments ->
editItem()
true
}
Focus Management
// Request focus
view.sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_FOCUSED)
// Announce message
view.announceForAccessibility("Item added to cart")
// Set focus order
view1.accessibilityTraversalAfter = view0.id
view2.accessibilityTraversalAfter = view1.id
Color Contrast
WCAG 2.1 Requirements
Contrast ratios:
- Normal text (< 18pt): 4.5:1 (AA), 7:1 (AAA)
- Large text (≥ 18pt or bold 14pt): 3:1 (AA), 4.5:1 (AAA)
- UI components: 3:1
- Graphical objects: 3:1
Examples:
✓ #000000 on #FFFFFF = 21:1 (perfect)
✓ #595959 on #FFFFFF = 7:1 (AAA)
✓ #767676 on #FFFFFF = 4.6:1 (AA)
❌ #999999 on #FFFFFF = 2.8:1 (fail)
Tools:
- WebAIM Contrast Checker
- Stark (Figma plugin)
- Color Oracle (simulator)
Implementation
iOS - Dynamic Type and Colors:
// Support Dynamic Type
label.font = UIFont.preferredFont(forTextStyle: .body)
label.adjustsFontForContentSizeCategory = true
// Adaptive colors
label.textColor = UIColor.label // Adapts to light/dark mode
view.backgroundColor = UIColor.systemBackground
Android - Material Design:
#000000
#5F6368
@color/text_primary_day
#FFFFFF
Touch Target Size
Minimum Sizes
Guidelines:
- Apple: 44×44 pt minimum
- Android: 48×48 dp minimum
- WCAG 2.1: 44×44 CSS pixels
Implementation:
iOS:
button.frame = CGRect(x: 0, y: 0, width: 44, height: 44)
// Extend hit area without changing visual size
class TappableButton: UIButton {
override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
let margin: CGFloat = 10
let area = bounds.insetBy(dx: -margin, dy: -margin)
return area.contains(point)
}
}
Android:
// Programmatic
button.minimumWidth = 48.dp
button.minimumHeight = 48.dp
Forms and Input
Accessible Forms
iOS:
textField.accessibilityLabel = "Email address"
textField.accessibilityHint = "Enter your email for login"
textField.textContentType = .emailAddress
textField.keyboardType = .emailAddress
textField.autocorrectionType = .no
// Error handling
if !isValidEmail {
textField.accessibilityValue = "Invalid email address"
UIAccessibility.post(notification: .announcement,
argument: "Error: Invalid email address")
}
Android:
// Error
textInputLayout.error = "Invalid email address"
textInputLayout.announceForAccessibility("Error: Invalid email address")
Field Labels
iOS:
// Associate label with field
textField.accessibilityLabel = nameLabel.text
// Or use labeledBy
textField.accessibilityUserInputLabels = [nameLabel.text!]
Android:
Navigation
Focus Order
iOS:
// Set focus to first element
firstElement.accessibilityElementIsFocused = true
// Custom focus order
view.accessibilityElements = [element1, element2, element3]
Android:
// Focus order
// Programmatic
view.requestFocus()
view.sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_FOCUSED)
Skip Links
// Allow skipping repetitive content
skipButton.accessibilityLabel = "Skip navigation"
skipButton.accessibilityHint = "Jump to main content"
skipButton.addTarget(self, action: #selector(skipToMain))
Media Accessibility
Images
Informative images:
✓ "Graph showing 50% increase in sales"
✓ "Warning icon"
✓ "Profile photo of John Doe"
Decorative images:
Hide from screen readers:
iOS: imageView.isAccessibilityElement = false
Android: android:importantForAccessibility="no"
Video Captions
iOS - AVPlayer:
// Load captions
let asset = AVAsset(url: videoURL)
let group = asset.mediaSelectionGroup(forMediaCharacteristic: .legible)
// Enable captions
playerItem.select(caption, in: group)
Android - ExoPlayer:
// Enable captions
trackSelector.setParameters(
trackSelector.buildUponParameters()
.setPreferredTextLanguage("en")
)
Audio Description
- Provide alternative audio track describing visual information
- Include in video player controls
- Auto-select based on user preferences
Testing Accessibility
iOS Testing
Enable VoiceOver:
Settings → Accessibility → VoiceOver
Accessibility Inspector (Xcode):
Xcode → Open Developer Tool → Accessibility Inspector
- Audit for issues
- Inspect element properties
- Simulate VoiceOver
Automated testing:
func testAccessibility() {
let app = XCUIApplication()
app.launch()
let button = app.buttons["Add to cart"]
XCTAssertTrue(button.exists)
XCTAssertTrue(button.isHittable)
XCTAssertEqual(button.label, "Add to cart")
}
Android Testing
Enable TalkBack:
Settings → Accessibility → TalkBack
Accessibility Scanner:
- Download from Play Store
- Scan any screen
- Get improvement suggestions
Automated testing:
@Test
fun testAccessibility() {
val button = onView(withText("Add to cart"))
button.check(matches(isDisplayed()))
button.check(matches(isEnabled()))
// Check content description
onView(withId(R.id.button))
.check(matches(withContentDescription("Add to cart")))
}
Manual Testing Checklist
□ Navigate entire app with screen reader
□ All interactive elements reachable
□ Meaningful labels for all elements
□ Logical focus order
□ Forms completable with screen reader
□ Error messages announced
□ Dynamic content changes announced
□ Sufficient color contrast
□ Touch targets ≥ 44pt/48dp
□ Works with system text size settings
□ No time-based interactions
□ Captions for video
□ Alternative text for images
□ No flashing content (< 3 flashes/second)
Common Mistakes
- ❌ Missing accessibility labels
- ❌ Generic labels ("button", "image")
- ❌ Low color contrast
- ❌ Small touch targets
- ❌ Using only color to convey meaning
- ❌ Unlabeled form fields
- ❌ Inaccessible custom controls
- ❌ No keyboard/screen reader support
- ❌ Timeout-based interactions
- ❌ Poor focus management
Platform-Specific Features
iOS Accessibility Features
- VoiceOver: Screen reader
- Voice Control: Voice navigation
- Switch Control: External switches
- AssistiveTouch: Touch alternatives
- Display & Text Size: Larger text
- Reduce Motion: Minimize animations
- Color Filters: Color blindness support
Android Accessibility Features
- TalkBack: Screen reader
- Voice Access: Voice commands
- Switch Access: External switches
- Font size: System-wide text scaling
- Display size: Zoom
- Color correction: Color blindness modes
- Remove animations: Reduce motion
Reduce Motion
Respect User Preferences
iOS:
if UIAccessibility.isReduceMotionEnabled {
// Use fade instead of slide animation
view.alpha = 0
UIView.animate(withDuration: 0.3) {
view.alpha = 1
}
} else {
// Full animation
UIView.animate(withDuration: 0.5, delay: 0,
usingSpringWithDamping: 0.8, initialSpringVelocity: 0, options: []) {
view.transform = .identity
}
}
Android:
// Check setting
val animator = if (Settings.Global.getFloat(
contentResolver,
Settings.Global.ANIMATOR_DURATION_SCALE, 1f
) == 0f) {
// No animation
null
} else {
// Full animation
ObjectAnimator.ofFloat(view, "alpha", 0f, 1f)
}
Legal Requirements
Regulations
- US - ADA: Americans with Disabilities Act
- US - Section 508: Federal accessibility standards
- EU - EAA: European Accessibility Act (2025)
- UK - Equality Act 2010: Digital accessibility
- Canada - ACA: Accessible Canada Act
- Australia - DDA: Disability Discrimination Act
Compliance Levels
WCAG 2.1:
- Level A: Minimum (legal requirement)
- Level AA: Mid-range (recommended)
- Level AAA: Highest (not always achievable)
Target: WCAG 2.1 Level AA compliance
Resources
- Apple Accessibility: developer.apple.com/accessibility
- Android Accessibility: developer.android.com/guide/topics/ui/accessibility
- WCAG 2.1: w3.org/WAI/WCAG21/quickref
- WebAIM: webaim.org
- A11y Project: a11yproject.com
- Deque University: dequeuniversity.com
Conclusion
Accessibility is not optional—it's a fundamental aspect of great UX. By following WCAG 2.1 guidelines, testing with assistive technologies, and prioritizing inclusivity, you create apps that work for everyone while expanding your user base and meeting legal requirements.