Building Accessible Mobile Apps: WCAG Guidelines for Developers

Accessibility is not a feature. It is a quality standard. In Australia, approximately 4.4 million people live with a disability, and the Disability Discrimination Act 1992 requires that services, including digital services, be accessible to people with disabilities. Beyond legal requirements, accessible apps reach a larger audience and are better apps for everyone.

This guide covers the practical implementation of accessibility in iOS and Android apps, aligned with WCAG 2.1 guidelines at the AA level.

Understanding WCAG for Mobile

WCAG (Web Content Accessibility Guidelines) was originally written for web content, but its principles apply directly to mobile apps. The four principles are:

  1. Perceivable: Users can perceive the content (visual, auditory, tactile)
  2. Operable: Users can interact with all controls and navigation
  3. Understandable: Users can understand the content and how to use the interface
  4. Robust: Content works with current and future assistive technologies

Screen Reader

Support

iOS: VoiceOver

VoiceOver is Apple’s built-in screen reader. Making your app work with VoiceOver is the single most important accessibility investment.

// Basic accessibility labels
Image(systemName: "cart.fill")
    .accessibilityLabel("Shopping cart")

// Combine elements for better VoiceOver flow
HStack {
    Text("Price")
    Spacer()
    Text("$49.99")
}
.accessibilityElement(children: .combine)
// VoiceOver reads: "Price, $49.99"

// Custom accessibility for complex views
struct ProductCard: View {
    let product: Product

    var body: some View {
        VStack {
            ProductImage(url: product.imageURL)
            Text(product.name)
            Text(product.price.formatted())
            StarRating(rating: product.rating)
        }
        .accessibilityElement(children: .ignore)
        .accessibilityLabel(
            "\(product.name), \(product.price.formatted()), rated \(product.rating) out of 5 stars"
        )
        .accessibilityHint("Double tap to view product details")
        .accessibilityAddTraits(.isButton)
    }
}

Accessibility Actions

// Custom actions for swipeable cells
struct OrderRow: View {
    let order: Order

    var body: some View {
        HStack {
            Text(order.title)
            Spacer()
            Text(order.status)
        }
        .accessibilityElement(children: .combine)
        .accessibilityAction(named: "Reorder") {
            reorder(order)
        }
        .accessibilityAction(named: "Cancel order") {
            cancelOrder(order)
        }
    }
}

Android: TalkBack

TalkBack is Android’s screen reader. Use content descriptions and proper semantics:

// Jetpack Compose accessibility
@Composable
fun ProductCard(product: Product) {
    Card(
        modifier = Modifier
            .semantics(mergeDescendants = true) {
                contentDescription = buildString {
                    append(product.name)
                    append(", ")
                    append(product.formattedPrice)
                    append(", rated ${product.rating} out of 5 stars")
                }
            }
            .clickable { navigateToProduct(product) }
    ) {
        Column {
            AsyncImage(
                model = product.imageUrl,
                contentDescription = null, // Decorative, covered by card description
            )
            Text(product.name)
            Text(product.formattedPrice)
        }
    }
}

// Custom accessibility actions
@Composable
fun OrderRow(order: Order) {
    Row(
        modifier = Modifier.semantics {
            customActions = listOf(
                CustomAccessibilityAction("Reorder") {
                    reorder(order)
                    true
                },
                CustomAccessibilityAction("Cancel order") {
                    cancelOrder(order)
                    true
                },
            )
        }
    ) {
        Text(order.title)
        Spacer()
        Text(order.status)
    }
}

XML Views

<ImageButton
    android:layout_width="48dp"
    android:layout_height="48dp"
    android:src="@drawable/ic_cart"
    android:contentDescription="@string/shopping_cart"
    android:importantForAccessibility="yes" />

{/* Group related elements */}
<LinearLayout
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:focusable="true"
    android:importantForAccessibility="yes"
    android:contentDescription="@string/product_description">

    {/* Child elements are treated as one accessibility node */}
</LinearLayout>

Colour and Co

ntrast

Minimum Contrast Ratios

WCAG AA requires:

  • Normal text: 4.5:1 contrast ratio against the background
  • Large text (18pt or 14pt bold and above): 3:1 contrast ratio
  • UI components and graphics: 3:1 contrast ratio

Testing Contrast

// Helper to calculate contrast ratio
func contrastRatio(between color1: UIColor, and color2: UIColor) -> CGFloat {
    let l1 = relativeLuminance(of: color1)
    let l2 = relativeLuminance(of: color2)
    let lighter = max(l1, l2)
    let darker = min(l1, l2)
    return (lighter + 0.05) / (darker + 0.05)
}

func relativeLuminance(of color: UIColor) -> CGFloat {
    var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0
    color.getRed(&r, green: &g, blue: &b, alpha: nil)

    let rLinear = r <= 0.03928 ? r / 12.92 : pow((r + 0.055) / 1.055, 2.4)
    let gLinear = g <= 0.03928 ? g / 12.92 : pow((g + 0.055) / 1.055, 2.4)
    let bLinear = b <= 0.03928 ? b / 12.92 : pow((b + 0.055) / 1.055, 2.4)

    return 0.2126 * rLinear + 0.7152 * gLinear + 0.0722 * bLinear
}

Do Not Rely on Colour Alone

Colour should not be the only way to convey information. Supplement with:

  • Icons or symbols
  • Text labels
  • Patterns or shapes
  • Underlines for links
// Bad: only colour indicates error
TextField("Email", text: $email)
    .foregroundColor(isValid ? .primary : .red)

// Good: colour AND icon AND text
VStack(alignment: .leading) {
    TextField("Email", text: $email)
        .foregroundColor(isValid ? .primary : .red)
        .overlay(
            !isValid ?
                Image(systemName: "exclamationmark.circle")
                    .foregroundColor(.red)
                    .frame(maxWidth: .infinity, alignment: .trailing)
                    .padding(.trailing, 8)
                : nil
        )
    if !isValid {
        Text("Please enter a valid email address")
            .font(.caption)
            .foregroundColor(.red)
    }
}

Dynami

c Type

Both iOS and Android allow users to increase text size. Your app must respond to these settings.

iOS Dynamic Type

// SwiftUI: Automatic support with system fonts
Text("Hello World")
    .font(.body) // Automatically scales with Dynamic Type

// Custom fonts that scale
Text("Custom Font")
    .font(.custom("Helvetica", size: 16, relativeTo: .body))

// Set minimum and maximum sizes
Text("Bounded Text")
    .font(.body)
    .dynamicTypeSize(.small ... .accessibility3)

Ensure your layouts handle larger text without truncation or overlap:

// Use ScrollView for content that might overflow
ScrollView {
    VStack(alignment: .leading, spacing: 16) {
        Text(title)
            .font(.headline)
        Text(body)
            .font(.body)
    }
    .padding()
}

Android: Font Scaling

// Compose: Use sp units for text (scales with system settings)
Text(
    text = "Hello World",
    fontSize = 16.sp, // Scales with accessibility settings
)

// Ensure layouts handle larger text
@Composable
fun AdaptiveRow(label: String, value: String) {
    // On large text, switch from row to column
    val configuration = LocalConfiguration.current
    val fontScale = configuration.fontScale

    if (fontScale > 1.3f) {
        Column {
            Text(label, style = MaterialTheme.typography.bodyMedium)
            Text(value, style = MaterialTheme.typography.bodyLarge)
        }
    } else {
        Row {
            Text(label, style = MaterialTheme.typography.bodyMedium)
            Spacer()
            Text(value, style = MaterialTheme.typography.bodyLarge)
        }
    }
}

Touch Target Sizes

WCAG 2.1 recommends touch targets of at least 44x44 points (iOS) or 48x48 dp (Android):

// iOS: Ensure minimum touch target
Button(action: { }) {
    Image(systemName: "plus")
        .frame(width: 44, height: 44)
}
// Android: Ensure minimum touch target
IconButton(
    onClick = { },
    modifier = Modifier.size(48.dp),
) {
    Icon(Icons.Default.Add, contentDescription = "Add item")
}

Small touch targets are frustrating for all users and impossible for users with motor impairments. This is one of the most common accessibility failures in mobile apps.

Motion and Animation

Some users are sensitive to motion. Respect the reduced motion preference:

// iOS: Check Reduce Motion setting
@Environment(\.accessibilityReduceMotion) var reduceMotion

var body: some View {
    content
        .animation(reduceMotion ? nil : .spring(), value: isExpanded)
}
// Android: Check animation scale
val context = LocalContext.current
val animationsEnabled = Settings.Global.getFloat(
    context.contentResolver,
    Settings.Global.ANIMATOR_DURATION_SCALE,
    1f
) > 0f

if (animationsEnabled) {
    AnimatedVisibility(visible = isVisible) {
        content()
    }
} else {
    if (isVisible) { content() }
}

Form Accessibility

Forms are where accessibility failures are most impactful:

// Accessible form field
struct AccessibleTextField: View {
    let label: String
    @Binding var text: String
    var error: String?
    var hint: String?

    var body: some View {
        VStack(alignment: .leading, spacing: 4) {
            Text(label)
                .font(.subheadline)
                .foregroundColor(.secondary)

            TextField(label, text: $text)
                .textFieldStyle(.roundedBorder)
                .accessibilityLabel(label)
                .accessibilityValue(text.isEmpty ? "Empty" : text)
                .accessibilityHint(hint ?? "")

            if let error = error {
                Text(error)
                    .font(.caption)
                    .foregroundColor(.red)
                    .accessibilityLabel("Error: \(error)")
            }
        }
    }
}

Testing Accessibility

Manual Testing

  1. Enable VoiceOver/TalkBack and navigate your entire app using only the screen reader
  2. Increase text size to the maximum and verify layouts still work
  3. Enable high contrast mode and verify visibility
  4. Test with Switch Control (iOS) or Switch Access (Android)
  5. Navigate using only keyboard (external keyboard connected to device)

Automated Testing

// iOS: XCTest accessibility audit
func testAccessibility() throws {
    let app = XCUIApplication()
    app.launch()

    // Check all elements have accessibility labels
    let elements = app.descendants(matching: .any)
    for i in 0 ..< elements.count {
        let element = elements.element(boundBy: i)
        if element.isHittable {
            XCTAssertFalse(
                element.label.isEmpty,
                "Element at index \(i) has no accessibility label"
            )
        }
    }
}

Tools

  • Xcode Accessibility Inspector: Audit accessibility properties of any element
  • Android Accessibility Scanner: App that scans your UI and reports issues
  • Colour contrast analysers: WebAIM Contrast Checker, Stark plugin for Figma

The Disability Discrimination Act 1992 does not specify technical standards, but Australian courts have referenced WCAG as the benchmark for digital accessibility. The Australian Human Rights Commission has accepted complaints about inaccessible websites and apps. Meeting WCAG 2.1 AA is the practical standard for compliance in Australia.

Conclusion

Accessibility is not a checklist to complete once. It is an ongoing practice that should be embedded in your design and development process. Start with screen reader support and colour contrast, then expand to dynamic type, touch targets, and motion sensitivity.

The investment pays off in a larger addressable market, legal compliance, and a better app for every user. An app that works well with accessibility features enabled works better for everyone.

For help building accessible mobile apps that meet WCAG standards, contact eawesome. We build inclusive mobile experiences for Australian businesses and their users.