5.13 — Accessibility

Opening scenario

Apple’s App Store review team has rejected your update. Reason: “VoiceOver users cannot complete a checkout — the ‘Buy’ button is not announced, and the price stepper is unreachable.”

You’ve never tested with VoiceOver. You’ve never used Dynamic Type. You assume “Accessibility” means screen-reader-for-blind-people and your app doesn’t really need it because most users aren’t blind.

You’re wrong on every count:

  • ~20% of users have some accessibility need: low vision, motor impairments, hearing loss, cognitive differences.
  • Dynamic Type is used by ~30% of iOS users (Apple’s internal data).
  • VoiceOver, Switch Control, Voice Control, AssistiveTouch are gateways for many of these users.
  • App Store review actively rejects updates with broken accessibility.
  • Lawsuits under ADA (US), EAA (EU 2025+) are real and growing.

SwiftUI gets accessibility mostly right by default — but only if you don’t actively break it (custom controls, decorative views without proper labels, layouts that don’t reflow with Dynamic Type). And the “mostly” isn’t enough; you have to add semantic info for screen readers.

This chapter is the playbook: testing, fixing, designing for accessibility from the start.

Accessibility areaSwiftUI support
Screen reader (VoiceOver)accessibilityLabel, accessibilityHint, accessibilityValue, accessibilityAction
Dynamic TypeAutomatic for Text, Label; @ScaledMetric for custom dimensions
Reduce Motion@Environment(\.accessibilityReduceMotion)
Reduce Transparency@Environment(\.accessibilityReduceTransparency)
Differentiate Without Color@Environment(\.accessibilityDifferentiateWithoutColor)
Bold TextAutomatic for system fonts
Increase Contrast@Environment(\.colorSchemeContrast)
Switch Control / Voice ControlInherited from VoiceOver labels
Focus orderaccessibilityElement(children:), accessibilitySortPriority
RotoraccessibilityRotor

Concept → Why → How → Code

VoiceOver and the accessibility tree

When VoiceOver is on, iOS/macOS reads the accessibility tree — a separate hierarchy from the rendering hierarchy. Each accessible element has:

  • Label — what it is (“Buy Button”)
  • Value — current state (“Selected”, “$29.99”, “Slider: 50%”)
  • Hint — what happens on activation (“Double-tap to purchase”)
  • Traits — semantic role (button, header, image, link, adjustable, selected)

SwiftUI auto-generates these for standard controls (Button, Toggle, TextField, etc.) from your labels. Custom controls need explicit annotation.

accessibilityLabel, accessibilityHint, accessibilityValue

// Icon-only button — bad
Button(action: favorite) {
    Image(systemName: "star.fill")
}
// VoiceOver reads "star fill" (the SF Symbol name) — useless

// Fixed
Button(action: favorite) {
    Image(systemName: "star.fill")
}
.accessibilityLabel("Add to favorites")
.accessibilityHint("Saves this item to your favorites list")

Label (the SwiftUI view, not the modifier) does this for free:

Button(action: favorite) {
    Label("Add to favorites", systemImage: "star.fill")
        .labelStyle(.iconOnly)  // visually only icon
}
// VoiceOver still hears "Add to favorites"

Prefer Label + .labelStyle(.iconOnly) over bare Image + accessibilityLabel.

accessibilityElement(children:)

Default: each subview is a separate accessibility element. Sometimes you want to combine them:

HStack {
    Image(systemName: "person.fill")
    VStack(alignment: .leading) {
        Text("Sara")
        Text("Online").font(.caption).foregroundStyle(.green)
    }
}
.accessibilityElement(children: .combine)
.accessibilityLabel("Sara, online")

Options:

  • .ignore — children not announced; only this view’s explicit label
  • .combine — children’s labels concatenated
  • .contain — children separate but grouped (good for nested grouping)

Traits

Text("Section Header")
    .font(.title2)
    .accessibilityAddTraits(.isHeader)

Image("decorative-divider")
    .accessibilityHidden(true)   // not in tree

Image("hero-photo")
    .accessibilityLabel("Sunset over Golden Gate Bridge")
    .accessibilityRemoveTraits(.isImage)
    .accessibilityAddTraits(.isImage)  // ensure trait

Common traits:

  • .isButton, .isHeader, .isImage, .isLink, .isSearchField, .isSelected, .isModal, .isSummaryElement, .updatesFrequently
  • .isStaticText, .allowsDirectInteraction, .causesPageTurn

Headers (.isHeader) let VoiceOver users navigate by heading (rotor → headings → swipe). Critical for long screens.

accessibilityHidden(_:)

For decorative views that shouldn’t be in the tree:

Image("subtle-pattern")
    .accessibilityHidden(true)

// Or hide entire decorative subtrees
DecorativeBackground()
    .accessibilityHidden(true)

accessibilityAction

Custom actions that VoiceOver surfaces:

NoteCard(note: note)
    .accessibilityAction(named: "Delete") {
        delete(note)
    }
    .accessibilityAction(named: "Toggle favorite") {
        note.isFavorite.toggle()
    }
    .accessibilityAction(.magicTap) {
        // invoked by 2-finger double tap with VoiceOver
        playPause()
    }

VoiceOver users hear “Actions available” and can browse via rotor. Far better than requiring complex gestures.

For swipe-to-delete in a List, the swipe action is exposed automatically as an accessibility action.

Adjustable values

For sliders, steppers, custom adjustable controls:

struct StarRating: View {
    @Binding var rating: Int

    var body: some View {
        HStack {
            ForEach(1...5, id: \.self) { star in
                Image(systemName: star <= rating ? "star.fill" : "star")
            }
        }
        .accessibilityElement(children: .ignore)
        .accessibilityLabel("Rating")
        .accessibilityValue("\(rating) of 5 stars")
        .accessibilityAdjustableAction { direction in
            switch direction {
            case .increment: rating = min(5, rating + 1)
            case .decrement: rating = max(0, rating - 1)
            @unknown default: break
            }
        }
    }
}

VoiceOver users swipe up/down to adjust — the standard gesture for sliders.

Dynamic Type

Text("Title").font(.title)              // scales with Dynamic Type
Text("Body").font(.body)
Text("Caption").font(.caption)

// Custom font that scales:
Text("Custom").font(.system(size: 17, weight: .semibold, design: .rounded))
// Use:
Text("Custom").font(.system(.body, design: .rounded))
// Or with explicit text style mapping:
Text("Custom").font(.custom("Helvetica", size: 17, relativeTo: .body))

Test at extreme sizes: Settings → Accessibility → Display & Text Size → Larger Text → drag to max (AX5). Or in code:

ContentView()
    .dynamicTypeSize(.accessibility5)

Common breakages:

  • Text truncates in narrow containers → use .lineLimit(nil) and .minimumScaleFactor(0.8) selectively, or reflow
  • Icons too small relative to giant text → use @ScaledMetric for sizes
  • Buttons overlap with surrounding content → use ViewThatFits to switch layouts
  • Toolbar items get clipped → switch to overflow menu

@ScaledMetric:

@ScaledMetric(relativeTo: .body) var iconSize: CGFloat = 24

Image(systemName: "star")
    .resizable()
    .frame(width: iconSize, height: iconSize)

Scales the value relative to the user’s Dynamic Type setting.

ViewThatFits for adaptive layouts

ViewThatFits(in: .horizontal) {
    HStack {
        Image(systemName: "star")
        Text("Add to favorites")
    }
    Image(systemName: "star")  // icon only fallback
}

When Dynamic Type makes the labeled version too wide, the icon-only fallback shows. Always pair with accessibilityLabel on the icon so VoiceOver still gets the text.

Reduce Motion

@Environment(\.accessibilityReduceMotion) var reduceMotion

withAnimation(reduceMotion ? .none : .spring(duration: 0.4)) {
    isExpanded.toggle()
}

Or use SwiftUI’s automatic respect (covered in 5.7) — much animation infrastructure respects this automatically, but you should double-check for custom transitions.

Reduce Transparency, Differentiate Without Color, Increase Contrast

@Environment(\.accessibilityReduceTransparency) var reduceTransparency
@Environment(\.accessibilityDifferentiateWithoutColor) var diffColor
@Environment(\.colorSchemeContrast) var contrast

// Reduce Transparency: swap blur backgrounds for opaque
background(reduceTransparency ? .gray.opacity(0.95) : .ultraThinMaterial)

// Differentiate Without Color: add icon/pattern alongside color
HStack {
    Circle().fill(.red)
    if diffColor {
        Image(systemName: "exclamationmark.triangle.fill")
    }
    Text("Error")
}

// Increase Contrast: switch to higher-contrast colors
foregroundStyle(contrast == .increased ? .black : .secondary)

accessibilityRotor — custom rotor entries

Rotor is VoiceOver’s “type-of-thing browser” (links, headers, form controls). You can publish custom rotors:

ScrollView {
    LazyVStack {
        ForEach(messages) { message in
            MessageRow(message: message)
                .id(message.id)
        }
    }
}
.accessibilityRotor("Unread Messages") {
    ForEach(messages.filter(\.isUnread)) { message in
        AccessibilityRotorEntry(message.preview, id: message.id)
    }
}

VoiceOver users open the rotor, see “Unread Messages”, and can jump between them. Common for inboxes, search results, error fields.

.accessibilityFocused — programmatic focus

@AccessibilityFocusState private var focusedField: Field?

enum Field { case email, password }

TextField("Email", text: $email)
    .accessibilityFocused($focusedField, equals: .email)

Button("Submit") {
    if email.isEmpty {
        focusedField = .email  // VoiceOver jumps and announces
    }
}

Critical for forms — after a validation error, focus the offending field so VoiceOver users know what to fix.

accessibilityRepresentation

Replace what VoiceOver “sees” with a different view:

ColorCircle(color: .red)
    .accessibilityRepresentation {
        Text("Red")
    }

Apple’s recommendation: design the represented view as if it were the actual control, then VoiceOver/Switch Control get the right semantics for free.

Custom controls — full picture

A custom slider built from gestures + shapes:

struct CustomSlider: View {
    @Binding var value: Double
    let range: ClosedRange<Double>

    var body: some View {
        // ... gesture and rendering code ...
        track
            .gesture(dragGesture)
            .accessibilityElement(children: .ignore)
            .accessibilityLabel("Volume")
            .accessibilityValue("\(Int(value * 100))%")
            .accessibilityAdjustableAction { direction in
                switch direction {
                case .increment: value = min(range.upperBound, value + 0.1)
                case .decrement: value = max(range.lowerBound, value - 0.1)
                @unknown default: break
                }
            }
    }
}

Without these, VoiceOver users cannot use the control. With them, it’s identical to native Slider from their POV.

Testing

Accessibility Inspector (Mac app, free, ships with Xcode):

  • Launch from Xcode → Open Developer Tool → Accessibility Inspector
  • Point at Simulator or device
  • Audit tab → checks labels, contrast, hit-target size
  • Inspection tab → see the accessibility tree as VoiceOver would

VoiceOver in Simulator:

  • ⌘5 (toggle VoiceOver) — uses macOS VoiceOver to read the Simulator
  • Better: physical device for realistic experience

VoiceOver gestures (device):

  • Single tap: select & announce
  • Double tap: activate
  • Swipe right/left: next/previous element
  • Two-finger swipe up: read all
  • Magic tap (2-finger double tap): primary action
  • Rotor: two-finger rotate

Dynamic Type:

  • Settings → Accessibility → Display & Text Size → Larger Text
  • Drag the slider; test your app at every size

Switch Control:

  • Settings → Accessibility → Switch Control → enable, use external switch or screen taps
  • Verifies focus order and reachability

Voice Control:

  • “Show numbers” / “Show names” — overlay numbers/names on tappable elements
  • Tap-target labels rely on your accessibility labels

Smell tests in code review

  • Image(systemName: "...") inside a Button without an accessibilityLabel or wrapping Label → reject
  • Custom controls without accessibilityAdjustableAction or accessibilityAction → reject
  • Fixed font(.system(size: 14)) for body text → reject (use text styles)
  • Frames in pt for icons that don’t scale → suggest @ScaledMetric
  • Animation without considering reduceMotion → review
  • Colored badges/status without icon/text differentiation → review

In the wild

  • Apple’s apps are best-in-class for accessibility — Reminders, Notes, Mail are fully usable with VoiceOver only.
  • Stripe’s apps have excellent form accessibility — every field has labels, errors are announced.
  • Twitter (RIP) and Instagram were criticized historically for poor accessibility; iOS-native rebuilds improved this.
  • Banking apps are heavily scrutinized — government regulations + the user base requires accessibility.
  • Apple’s “Built for All” annual blog series showcases apps with excellent accessibility.

Common misconceptions

  1. “My app doesn’t need accessibility; most users aren’t disabled.” ~20% of users have some accessibility need. Dynamic Type users alone are ~30%. Plus: legal requirements, App Store reviews, and “designing for accessibility” generally produces better UI for everyone.
  2. “SwiftUI handles accessibility for me.” Mostly, but custom controls, icon-only buttons, decorative views, and Dynamic Type-breaking layouts are your responsibility.
  3. accessibilityLabel is enough.” Often you need label + value + hint + traits + actions. A button with state (toggle) needs all four.
  4. “Just turn on VoiceOver once at the end of the project.” Bake it in from the start. Retrofitting accessibility into a finished app costs 5-10x more.
  5. “Dynamic Type breaks our designs; we’ll cap the font size.” Apps that cap Dynamic Type below AX1 are flagged in App Store review and frustrate users. Design layouts that adapt.

Seasoned engineer’s take

Accessibility is not a checkbox — it’s a discipline. Teams that get it right have:

  1. Accessibility audits in CI — Accessibility Inspector audits, or scripted XCUITest with accessibilityActivate() checks.
  2. A team member assigned as the accessibility champion — reviews every PR for accessibility regressions.
  3. VoiceOver testing in every sprint — at least one feature touched with VoiceOver before ship.
  4. Dynamic Type at AX5 included in design reviews — if it breaks, redesign.
  5. Default-on accessibility traits — instead of forgetting, code is structured so labels are required (e.g., custom view types that require an accessibilityLabel initializer parameter).

The argument “we’ll add accessibility later” is the same as “we’ll add tests later” — it never happens, and the eventual cost is far higher than building it in.

The good news: SwiftUI makes 80% of accessibility automatic if you use standard controls (Button, Label, Toggle, Slider, TextField, Form, List, NavigationStack). The other 20% (custom controls, icon-only UI, custom gestures, Dynamic Type-aware layouts) needs deliberate work.

Hire and listen to disabled users. The best accessibility insights come from people who use these technologies daily.

TIP: Add “Accessibility QA” as a step in your release checklist. At minimum: full VoiceOver pass through the primary flow, Dynamic Type AX5 visual check, Reduce Motion check on animation-heavy screens.

WARNING: Don’t use accessibilityHidden(true) to hide UI you’re too lazy to label. If it’s visible, users with assistive tech expect to be able to interact with it.

Interview corner

Junior-level: “How do you make an icon-only button accessible?”

Either wrap the icon in a Label with .labelStyle(.iconOnly) (preferred — single source of truth for the name), or apply .accessibilityLabel("…") to the button. The Label approach is better because the accessibility text comes from the same string you’d use for the visual label, reducing drift.

Mid-level: “A custom slider built from a Capsule, a Circle, and a DragGesture is not usable with VoiceOver. How do you fix it?”

  1. Mark the whole control as a single accessibility element with .accessibilityElement(children: .ignore) so the individual shapes don’t pollute the tree.
  2. Add .accessibilityLabel("Volume") (or whatever it represents).
  3. Add .accessibilityValue("\(Int(value * 100))%") so VoiceOver announces the current state.
  4. Add .accessibilityAdjustableAction { direction in ... } to handle VoiceOver’s swipe-up/swipe-down increments. Increment/decrement by a sensible step.
  5. Optionally: .accessibilityAddTraits(.isSlider) (though accessibilityAdjustableAction implies it).

Now VoiceOver users hear “Volume, 50%, slider, swipe up to increment” and can adjust.

Senior-level: “Design an accessibility strategy for a 200-screen app that has had accessibility ignored for 3 years.”

  1. Audit & prioritize: Run Accessibility Inspector audit on every screen — produces a backlog. Categorize: (a) blockers (control unreachable, no label), (b) usability (poor labels, missing actions), (c) polish (better announcements, custom rotors). Triage by user-facing impact (login screen first, settings last).
  2. Establish baseline rules: Lint for Image(systemName:) without accessibilityLabel or Label. CI fails PRs that introduce regressions.
  3. Tackle highest-impact screens first: Authentication, primary flows, payment. Get them VoiceOver-clean. Each gets a dedicated VoiceOver QA pass.
  4. Refactor reusable components first: PrimaryButton, custom form fields, custom navigation — fixing once propagates to all uses.
  5. Add Dynamic Type tests: Snapshot tests at sizes Body, Large, XL, AX5. Visual diff reveals layout breakage.
  6. Onboard team: Lunch-and-learn sessions, accessibility champion appointed, design-review checklist updated.
  7. Hire accessibility consultants: External audit at the end to catch what the team misses. Apple’s Accessibility Consultancy team provides feedback for high-profile apps.
  8. Continuous integration: UI tests with VoiceOver activated assertions (XCTAccessibility API), Accessibility Inspector audits in CI.
  9. User research with disabled users: Recruit through accessibility advocacy organizations. Watch them use the app. Insights you can’t get otherwise.
  10. Track metrics: Accessibility bug count over time, AX5 layout compliance percentage, “VoiceOver score” per release.

Red flag in candidates: Treating accessibility as a “nice to have” or “specialized feature”. Or saying “we’ll only support default Dynamic Type sizes”.

Lab preview

The labs in this phase implicitly require accessibility — when you ship the Todo app, Animated Dashboard, Multiplatform Notes, or Component Library, run them with VoiceOver and Dynamic Type AX3 to verify they’re usable. Component library especially: every published component should have built-in accessibility (label parameter required, sensible defaults).


Phase 5 chapters complete. Continue with Lab 5.1 — Todo app.