Most engineering teams build features for themselves first, then retrofit accessibility later—if at all. By that point, you're rewriting core UI logic and fighting against architectural decisions. Accessibility in SwiftUI: a hands-on guide for engineers shows you how to embed accessible practices into your build process from day one, with minimal overhead and maximum reach. We'll walk through concrete patterns that let visually impaired users, those with motor limitations, and people in high-motion environments all navigate your app smoothly.
Why accessibility matters to your business
Accessibility isn't charity—it's engineering. In the UK alone, 11 million people live with sight loss; globally, over a billion people experience some form of disability. If your app is on the App Store, you're potentially excluding a market segment worth billions in aggregate spending power. iOS users also tend to enable accessibility features at higher rates than other platforms because the OS has had strong VoiceOver and dynamic text support for over a decade.
From a technical perspective, accessible code is usually more maintainable. When you label your UI elements properly and respect system settings for motion and contrast, you're forced to separate concerns, test more thoroughly, and write less fragile layout logic. Teams that prioritize accessibility often report fewer crashes and simpler view hierarchies.
Labels: accessibilityLabel for icons and images
VoiceOver users hear nothing when they tap an unlabeled icon. Your app becomes a black box. The fix is straightforward: every image, button, and interactive element without visible text needs an accessibilityLabel.
Start with images that convey meaning:
Image(systemName: "heart.fill")
.foregroundColor(.red)
.accessibilityLabel("Add to favorites")
Notice we didn't label it "heart.fill"—that's the system name, not the user-facing meaning. VoiceOver users need to know what the button does, not what icon it displays.
For interactive elements without text, use both accessibilityLabel and accessibilityHint:
Button(action: { presentSettings() }) {
Image(systemName: "gear")
}
.accessibilityLabel("Settings")
.accessibilityHint("Opens the app settings screen")
The label is what VoiceOver announces immediately; the hint is optional context that appears after a pause, like a tooltip for screen reader users.
For decorative images—say, a background pattern or an icon purely for visual rhythm—hide them from accessibility entirely:
Image("decorative-border")
.accessibilityHidden(true)
This prevents VoiceOver from announcing every decorative element and cluttering the experience. A common mistake is labeling decorative content, which slows down navigation for blind users.
Complex images like charts or diagrams need more thought. A single label won't do: "Chart showing Q4 revenue" tells you nothing useful. Consider adding a accessibilityValue with a summary, or link to a detailed text description elsewhere in the UI:
Image("revenue-chart")
.accessibilityLabel("Q4 Revenue by Region")
.accessibilityValue("North: $2.1M, South: $1.8M, West: $2.4M, East: $1.9M")
Dynamic Type: use text styles so fonts scale
On iOS, users can enable Larger Accessibility Sizes to make text bigger—sometimes dramatically. If your layout is hardcoded to 16-point Helvetica, you'll break older users' workflows and frustrate them into uninstalling.
Always use semantic text styles:
VStack(spacing: 12) {
Text("Welcome")
.font(.largeTitle)
Text("Your daily summary")
.font(.title2)
Text("You've completed 3 of 5 tasks today.")
.font(.body)
Text("Tap to view details")
.font(.caption)
}
These styles scale proportionally when users enable Large or Extra-Large text sizes. The system handles the math; you just declare the hierarchy.
Test your layouts at multiple sizes. Xcode's preview supports this:
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
Group {
ContentView()
.environment(\.sizeCategory, .small)
.previewDisplayName("Small")
ContentView()
.environment(\.sizeCategory, .extraExtraExtraLarge)
.previewDisplayName("Extra Large")
}
}
}
Run this preview and watch your layout adapt. If text overflows or buttons become unreachable, adjust your spacing and constraints. Remember: a user with vision loss who relies on larger text is not an edge case; they're part of your core audience.
Avoid fixed frame heights for text:
// Bad: text gets cut off at larger sizes
Text("Hello").frame(height: 20)
// Good: text size determines height
Text("Hello").lineLimit(1)
VoiceOver: test flows with VoiceOver on
Reading your accessibility labels in Xcode is not the same as navigating your app blindfolded. Spend an hour actually using VoiceOver. It's humbling and reveals problems no code review will catch.
Enable VoiceOver on your device: Settings → Accessibility → VoiceOver → On. Or in the simulator: Cmd+U then search for VoiceOver.
Now navigate your app. Swipe right to move forward, swipe left to move backward. Double-tap to activate. Flick up then down (two-finger) to read everything on screen. You'll discover:
- Which buttons are unreachable because their labels are cut off mid-word.
- Which container grouping is confusing (five separate images announced individually instead of one cohesive card).
- Which interactive elements have no labels at all and are silent voids.
- Which tab order is backwards because SwiftUI's default navigation order doesn't match your visual layout.
Fix tab order with accessibilityElement(children:):
VStack {
Button("Save") { save() }
Button("Cancel") { cancel() }
}
.accessibilityElement(children: .combine)
This tells VoiceOver to treat the whole VStack as one element, reducing verbosity. Alternatively, set explicit navigation:
Button("Save") { save() }
.accessibilityElement()
Button("Cancel") { cancel() }
.accessibilityElement()
If you have a list or carousel, group items logically so VoiceOver doesn't announce every thumbnail separately:
VStack(spacing: 16) {
ForEach(products) { product in
HStack {
Image(product.imageName)
VStack(alignment: .leading) {
Text(product.name).font(.headline)
Text(product.price).font(.body)
}
}
.accessibilityElement(children: .combine)
.accessibilityLabel(product.name)
.accessibilityValue(product.price)
}
}
Now each product is one navigable item with a name and price, not five separate elements.
Contrast and motion: respect Reduce Motion and Increase Contrast
Not all accessibility is about vision or hearing. Some users have vestibular disorders that trigger nausea from motion; others have low vision and need higher contrast text.
Reduce Motion: If your app has animated transitions, parallax effects, or bouncing icons, check the system setting before enabling them:
@Environment(\.accessibilityReduceMotion) var reduceMotion
var body: some View {
VStack {
Image("logo")
.scaleEffect(reduceMotion ? 1.0 : 1.2)
.animation(reduceMotion ? nil : .spring())
}
}
When reduceMotion is true, the animation disappears entirely. The image still appears; it just doesn't bounce. This is kind and costs nothing.
For transitions between screens, check before using .transition():
if isPresented {
SettingsView()
.transition(reduceMotion ? .identity : .slide)
}
Increase Contrast: The system also offers a setting to bump up contrast for users with low vision. Respect it with lighter text on dark backgrounds and darker text on light ones. SwiftUI's built-in colors like .primary and .secondary adapt automatically:
Text("Important info")
.foregroundColor(.primary) // Respects Increase Contrast
.font(.body)
Avoid custom colors that don't meet WCAG AA standards (4.5:1 contrast for normal text). Use a contrast checker tool; it takes two minutes and prevents accessibility failures later.
Common mistakes
- Labeling everything: Don't add a label to every Text view. VoiceOver reads visible text automatically. Label only images, icons, and hidden elements.
- Ignoring system settings: Building an app that ignores
reduceMotionorlargeContentSizeCategoryis like ignoring the user's choice of language. Test at multiple size categories and with motion disabled. - Complex labels: "Tap the three-line menu button in the top-left to navigate" is too verbose. "Menu" or "Navigation" is enough. Be concise.
- No tab order testing: Assuming default navigation is correct without testing. Always verify with VoiceOver on.
- Decorative elements stealing focus: Dividers, spacers, and background images shouldn't be announced. Use
accessibilityHidden(true)liberally.
Key takeaways
- Every icon and image without text needs an
accessibilityLabeldescribing its purpose, not its appearance. - Use semantic text styles (
.body,.largeTitle, etc.) so your layouts scale with user-selected text sizes. - Test with VoiceOver enabled on real devices or simulator for at least an hour per release cycle.
- Disable animations and motion effects when
accessibilityReduceMotionis enabled to prevent vestibular discomfort. - Group related UI elements with
accessibilityElement(children: .combine)to reduce navigation noise. - Use system colors like
.primaryand.secondaryto respect contrast settings automatically. - Accessibility code is maintenance code; it makes your app more robust and readable for all engineers on the team.






Comments
Loading comments…