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 area | SwiftUI support |
|---|---|
| Screen reader (VoiceOver) | accessibilityLabel, accessibilityHint, accessibilityValue, accessibilityAction |
| Dynamic Type | Automatic for Text, Label; @ScaledMetric for custom dimensions |
| Reduce Motion | @Environment(\.accessibilityReduceMotion) |
| Reduce Transparency | @Environment(\.accessibilityReduceTransparency) |
| Differentiate Without Color | @Environment(\.accessibilityDifferentiateWithoutColor) |
| Bold Text | Automatic for system fonts |
| Increase Contrast | @Environment(\.colorSchemeContrast) |
| Switch Control / Voice Control | Inherited from VoiceOver labels |
| Focus order | accessibilityElement(children:), accessibilitySortPriority |
| Rotor | accessibilityRotor |
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
@ScaledMetricfor sizes - Buttons overlap with surrounding content → use
ViewThatFitsto 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 aButtonwithout anaccessibilityLabelor wrappingLabel→ reject- Custom controls without
accessibilityAdjustableActionoraccessibilityAction→ reject - Fixed
font(.system(size: 14))for body text → reject (use text styles) - Frames in
ptfor 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
- “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.
- “SwiftUI handles accessibility for me.” Mostly, but custom controls, icon-only buttons, decorative views, and Dynamic Type-breaking layouts are your responsibility.
- “
accessibilityLabelis enough.” Often you need label + value + hint + traits + actions. A button with state (toggle) needs all four. - “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.
- “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:
- Accessibility audits in CI — Accessibility Inspector audits, or scripted XCUITest with
accessibilityActivate()checks. - A team member assigned as the accessibility champion — reviews every PR for accessibility regressions.
- VoiceOver testing in every sprint — at least one feature touched with VoiceOver before ship.
- Dynamic Type at AX5 included in design reviews — if it breaks, redesign.
- Default-on accessibility traits — instead of forgetting, code is structured so labels are required (e.g., custom view types that require an
accessibilityLabelinitializer 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?”
- Mark the whole control as a single accessibility element with
.accessibilityElement(children: .ignore)so the individual shapes don’t pollute the tree. - Add
.accessibilityLabel("Volume")(or whatever it represents). - Add
.accessibilityValue("\(Int(value * 100))%")so VoiceOver announces the current state. - Add
.accessibilityAdjustableAction { direction in ... }to handle VoiceOver’s swipe-up/swipe-down increments. Increment/decrement by a sensible step. - Optionally:
.accessibilityAddTraits(.isSlider)(thoughaccessibilityAdjustableActionimplies 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.”
- 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).
- Establish baseline rules: Lint for
Image(systemName:)withoutaccessibilityLabelorLabel. CI fails PRs that introduce regressions. - Tackle highest-impact screens first: Authentication, primary flows, payment. Get them VoiceOver-clean. Each gets a dedicated VoiceOver QA pass.
- Refactor reusable components first:
PrimaryButton, custom form fields, custom navigation — fixing once propagates to all uses. - Add Dynamic Type tests: Snapshot tests at sizes Body, Large, XL, AX5. Visual diff reveals layout breakage.
- Onboard team: Lunch-and-learn sessions, accessibility champion appointed, design-review checklist updated.
- 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.
- Continuous integration: UI tests with VoiceOver activated assertions (
XCTAccessibilityAPI), Accessibility Inspector audits in CI. - User research with disabled users: Recruit through accessibility advocacy organizations. Watch them use the app. Insights you can’t get otherwise.
- 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.