Back to all articles

Mobile App Accessibility: WCAG 2.1 Compliance Guide for iOS and Android

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

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:

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.

Need a Support URL for Your App?

Generate a compliant, professional support page in under a minute. Our easy-to-use generator creates everything you need for App Store and Google Play submissions.