Building Accessible SwiftUI Apps: VoiceOver, Dynamic Type, and Semantic Labels
1 in 4 Americans has a disability. When you skip accessibility, you’re excluding up to 25% of potential users before they’ve even opened your app. But accessibility has a second benefit that’s less talked about: the same improvements that make apps work for screen reader users — clear semantic labels, logical reading order, proper contrast — make apps more usable for everyone. The discipline of accessibility is the discipline of clear design.
This post covers the practical SwiftUI accessibility API: labels, hints, traits, Dynamic Type support, reduced motion handling, VoiceOver grouping, and testing with Accessibility Inspector. We won’t cover watchOS or tvOS specifics — this is focused on iPhone and iPad.
Contents
- The Problem
- Accessibility Labels, Hints, and Values
- Accessibility Traits
- Dynamic Type Support
- Reducing Motion
- VoiceOver Grouping
- Testing with Accessibility Inspector
- Advanced Usage
- When to Use (and When Not To)
- Summary
The Problem
Consider this custom Pixar movie favorite button — a common pattern, a common accessibility failure:
// ❌ Custom button with no accessibility configuration
struct FavoriteButton: View {
@Binding var isFavorited: Bool
var body: some View {
Button {
isFavorited.toggle()
} label: {
Image(systemName: isFavorited ? "star.fill" : "star")
.foregroundStyle(isFavorited ? .yellow : .secondary)
.font(.title2)
}
}
}
VoiceOver reads this as: “star fill, button” or “star, button” — depending on current state. That’s useless. A user navigating a list of Pixar films has no idea what they’re toggling or what film it applies to.
The hardcoded font size problem is equally insidious:
// ❌ Hardcoded font size ignores Dynamic Type
struct FilmTitleView: View {
let film: PixarFilm
var body: some View {
VStack(alignment: .leading) {
Text(film.title)
.font(.system(size: 18, weight: .bold)) // ignores accessibility size
Text(film.director)
.font(.system(size: 13)) // same problem
}
}
}
Users who set their text size to Accessibility Extra Large will see the same small 18pt font everyone else sees. This excludes users with low vision who rely on large text to read comfortably.
Both problems have straightforward fixes.
Accessibility Labels, Hints, and Values
SwiftUI’s accessibilityLabel,
accessibilityHint, and
accessibilityValue modifiers
provide the three pieces of information VoiceOver communicates to users.
- Label — the name of the element (“Add Toy Story to favorites”)
- Hint — what happens when you interact with it (“Double-tap to toggle favorite status”)
- Value — the current state of the element (“Favorited”)
// ✅ Fully described favorite button
struct FavoriteButton: View {
let filmTitle: String
@Binding var isFavorited: Bool
var body: some View {
Button {
isFavorited.toggle()
} label: {
Image(systemName: isFavorited ? "star.fill" : "star")
.foregroundStyle(isFavorited ? .yellow : .secondary)
.font(.title2)
}
.accessibilityLabel("Add \(filmTitle) to favorites")
.accessibilityHint("Double-tap to toggle favorite status")
.accessibilityValue(isFavorited ? "Favorited" : "Not favorited")
}
}
VoiceOver now reads: “Add Toy Story to favorites, button. Favorited. Double-tap to toggle favorite status.”
accessibilityIdentifier for UI Testing
.accessibilityIdentifier is
distinct from the label — it’s machine-readable only and has no effect on VoiceOver. Its purpose is UI testing with
XCUITest:
TextField("Search films", text: $searchText)
.accessibilityIdentifier("filmSearchField")
// In your XCUITest:
// let searchField = app.textFields["filmSearchField"]
// XCTAssert(searchField.exists)
Set identifiers on all interactive elements in screens you intend to test automatically. It’s a zero-cost annotation that pays dividends when writing UI tests.
When to Customize Labels
Standard SwiftUI controls — Button, Toggle, Slider, TextField — derive their accessibility labels from their
visible content automatically. You don’t need to annotate a Button("Play Movie") — VoiceOver already reads “Play
Movie, button.”
Customize labels when:
- The visible label is a symbol or icon with no text (
Image(systemName:)) - The visible text is too short to be meaningful out of context (“Add”, “Edit”, “X”)
- The label needs context from surrounding content (which film does this button operate on?)
- A decorative image should be hidden from VoiceOver entirely
Accessibility Traits
accessibilityAddTraits
annotates an element with semantic information about its role. VoiceOver uses traits to describe elements and change
interaction behavior.
// Announce this Text as a section header (VoiceOver says "heading")
Text("Pixar Classics")
.font(.title2).bold()
.accessibilityAddTraits(.isHeader)
// Announce selected state — useful for custom tab bars or filter chips
Text(era.name)
.padding(.horizontal, 12)
.background(isSelected ? Color.accentColor : Color.secondary.opacity(0.15))
.clipShape(Capsule())
.accessibilityAddTraits(isSelected ? [.isButton, .isSelected] : .isButton)
// Mark an image as decorative — VoiceOver skips it entirely
Image("pixar-logo-watermark")
.resizable()
.accessibilityRemoveTraits(.isImage) // removes default image trait
.accessibilityHidden(true) // hides from accessibility tree
.accessibilityHidden(true) removes
an element from the VoiceOver navigation entirely. Use it for decorative images, visual separators, and loading
indicators that have a separate accessible status label.
Common traits and when to use them:
| Trait | When to use |
|---|---|
.isHeader | Section titles, screen headings |
.isButton | Custom tappable areas that aren’t Button |
.isSelected | Currently selected item in a group |
.isImage | Default on Image — remove for decorative images |
.updatesFrequently | Live data (timers, stock prices, progress) |
.startsMediaSession | Buttons that begin audio or video playback |
.allowsDirectInteraction | Custom drawing views needing touch passthrough |
Dynamic Type Support
iOS users can set their preferred text size in Settings > Accessibility > Display & Text Size > Larger Text, across 12
sizes from Extra Small to Accessibility Extra Large 5. SwiftUI’s semantic font styles — .body, .headline, .caption
— scale automatically. Hardcoded system(size:) fonts do not.
// ✅ Scales with Dynamic Type automatically
Text(film.title)
.font(.headline)
Text(film.synopsis)
.font(.body)
Text(film.director)
.font(.caption)
.foregroundStyle(.secondary)
For non-text elements whose size should scale — icon frames, spacing, padding — use
@ScaledMetric:
struct FilmRatingView: View {
let rating: Double
// Scales relative to .body text size
@ScaledMetric(relativeTo: .body) private var starSize: CGFloat = 16
@ScaledMetric(relativeTo: .body) private var starSpacing: CGFloat = 4
var body: some View {
HStack(spacing: starSpacing) {
ForEach(0 ..< 5) { index in
Image(systemName: index < Int(rating) ? "star.fill" : "star")
.frame(width: starSize, height: starSize)
.foregroundStyle(.yellow)
}
}
}
}
@ScaledMetric takes an optional relativeTo category. If the user bumps text size up 50%, this icon also grows by 50%
relative to the .body baseline.
Layout Adjustments for Large Text
At Accessibility Extra Large sizes, horizontal layouts often break. Use
\.dynamicTypeSize from the environment to
conditionally switch between layouts:
struct FilmCardRow: View {
let film: PixarFilm
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
var body: some View {
// Switch from horizontal to vertical at large accessibility sizes
if dynamicTypeSize.isAccessibilitySize {
VStack(alignment: .leading, spacing: 8) {
filmPoster
filmDetails
}
} else {
HStack(alignment: .top, spacing: 12) {
filmPoster
filmDetails
}
}
}
private var filmPoster: some View {
AsyncImage(url: film.posterURL) { image in
image.resizable().aspectRatio(2/3, contentMode: .fit)
} placeholder: {
RoundedRectangle(cornerRadius: 6)
.fill(Color.secondary.opacity(0.2))
}
.frame(width: dynamicTypeSize.isAccessibilitySize ? nil : 60)
.frame(maxWidth: dynamicTypeSize.isAccessibilitySize ? .infinity : nil)
.clipShape(RoundedRectangle(cornerRadius: 6))
}
private var filmDetails: some View {
VStack(alignment: .leading, spacing: 4) {
Text(film.title).font(.headline)
Text(film.director).font(.subheadline).foregroundStyle(.secondary)
}
}
}
dynamicTypeSize.isAccessibilitySize returns true for the five Accessibility sizes (AX1–AX5). You don’t need a switch
over every size level — this binary split handles the majority of real-world layout breakages.
Reducing Motion
Some users have vestibular disorders, migraines, or motion sensitivity. iOS provides the Reduce Motion accessibility
setting to signal that animations should be minimal or absent. SwiftUI exposes this via the
\.accessibilityReduceMotion
environment value.
struct FilmSelectionView: View {
@State private var selectedFilm: PixarFilm?
@Environment(\.accessibilityReduceMotion) private var reduceMotion
var body: some View {
VStack {
ForEach(pixarFilms) { film in
FilmRow(film: film, isSelected: selectedFilm?.id == film.id)
.onTapGesture { selectedFilm = film }
}
}
// Use nil animation (instant state change) when reduce motion is on
.animation(reduceMotion ? nil : .spring(duration: 0.4), value: selectedFilm?.id)
}
}
For matched geometry transitions and hero animations — which involve movement across the screen — consider replacing
them with a simple fade when reduceMotion is true:
struct PixarHeroTransitionView: View {
@Namespace private var heroNamespace
@State private var isExpanded = false
@Environment(\.accessibilityReduceMotion) private var reduceMotion
var body: some View {
if isExpanded {
FilmDetailView(film: selectedFilm)
.matchedGeometryEffect(
id: "filmCard",
in: heroNamespace,
isSource: true
)
.transition(reduceMotion ? .opacity : .identity)
} else {
FilmCard(film: selectedFilm)
.matchedGeometryEffect(
id: "filmCard",
in: heroNamespace,
isSource: false
)
}
}
}
Tip: The Reduce Motion setting doesn’t mean “no animation.” It means “no animation that involves large-scale movement.” Subtle opacity fades and color transitions are generally fine. Sliding panels, page turns, and zoom transitions should be replaced or suppressed.
VoiceOver Grouping
SwiftUI’s default accessibility tree mirrors the view hierarchy. A card with five child views produces five separate VoiceOver navigation stops. That’s exhausting for a list of 20 film cards.
accessibilityElement(children: .combine)
merges all child accessibility information into a single node:
struct FilmSummaryCard: View {
let film: PixarFilm
var body: some View {
VStack(alignment: .leading, spacing: 6) {
Text(film.title).font(.headline)
Text("\(film.year) · \(film.director)")
.font(.subheadline)
.foregroundStyle(.secondary)
Text(film.synopsis)
.font(.caption)
.lineLimit(3)
}
.padding()
.background(Color.secondary.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 12))
// Merge all text into one VoiceOver element
.accessibilityElement(children: .combine)
}
}
With .combine, VoiceOver reads the card as a single stop: “Toy Story. 1995 · John Lasseter. A cowboy doll is
profoundly threatened…”
For more control, use .ignore and set a custom label on the container:
struct FilmPosterCard: View {
let film: PixarFilm
@Binding var isFavorited: Bool
var body: some View {
ZStack(alignment: .topTrailing) {
VStack {
AsyncImage(url: film.posterURL) { image in
image.resizable().aspectRatio(2/3, contentMode: .fill)
} placeholder: {
Rectangle().fill(Color.secondary.opacity(0.2))
}
Text(film.title).font(.caption).bold()
}
FavoriteButton(filmTitle: film.title, isFavorited: $isFavorited)
}
// The poster card itself is navigable with a summary label
.accessibilityElement(children: .contain) // children remain accessible
.accessibilityLabel(film.title)
}
}
.contain keeps children navigable (the FavoriteButton remains its own VoiceOver element) while also giving the
container its own label for rotor navigation.
Testing with Accessibility Inspector
Xcode ships with Accessibility Inspector (Xcode > Open Developer Tool > Accessibility Inspector). It audits running
apps on Simulator or a connected device for accessibility issues without requiring a VoiceOver-enabled device.
Key Accessibility Inspector workflows:
- Audit tab — runs automated checks and flags elements missing labels, low-contrast text, and touch targets smaller than 44×44 points. Run this on every new screen.
- Inspection tab — point the cursor at any element to see its label, value, hint, and traits as VoiceOver would read them. This is faster than enabling VoiceOver for quick spot-checking.
- Settings tab — simulate Dynamic Type sizes, Reduce Motion, Reduce Transparency, and other accessibility settings without toggling them in the device Settings app.
Tip: Run the Accessibility Inspector audit before every PR that touches UI. It catches the most common issues — missing labels, tiny tap targets, insufficient contrast — in under a minute.
Minimum Touch Target Size
Apple’s Human Interface Guidelines require interactive controls to have a minimum 44×44 point touch target. SwiftUI
doesn’t enforce this automatically. The solution is .frame(minWidth: 44, minHeight: 44) on Button labels or using
the .contentShape(Rectangle()) modifier:
Button {
isFavorited.toggle()
} label: {
Image(systemName: isFavorited ? "star.fill" : "star")
.font(.title3)
.frame(minWidth: 44, minHeight: 44) // guaranteed tap area
.contentShape(Rectangle()) // taps register in the full frame
}
Advanced Usage
Custom Accessibility Actions
accessibilityAction adds named
actions to an element’s VoiceOver action menu (invoked via the VoiceOver rotor). This is perfect for elements with
multiple operations — a film card that can be favorited, shared, or added to a watchlist:
FilmSummaryCard(film: film)
.accessibilityAction(named: "Add to favorites") {
favoritesManager.toggle(film)
}
.accessibilityAction(named: "Add to watchlist") {
watchlistManager.add(film)
}
.accessibilityAction(named: "Share") {
shareSheet.present(film)
}
VoiceOver users access these through the Actions rotor — swipe up/down while focused on the element to cycle through available actions.
AccessibilityCustomContent
AccessibilityCustomContent (iOS 15+)
adds supplementary information visible in the VoiceOver details rotor without cluttering the main label. Useful for
information that’s valuable on demand but not worth reading on every navigation stop:
@available(iOS 15.0, *)
FilmSummaryCard(film: film)
.accessibilityCustomContent("Duration", "\(film.durationMinutes) minutes")
.accessibilityCustomContent("Genre", film.genre)
.accessibilityCustomContent("Rating", film.contentRating)
Users access this information by activating the More Content rotor in VoiceOver settings — it’s surfaced only when they want it.
A Fully Accessible Pixar Film Card
Putting it all together — a production-ready film card component with comprehensive accessibility support:
struct AccessibleFilmCard: View {
let film: PixarFilm
@Binding var isFavorited: Bool
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@ScaledMetric(relativeTo: .body) private var posterWidth: CGFloat = 72
@ScaledMetric(relativeTo: .body) private var spacing: CGFloat = 12
var body: some View {
HStack(alignment: .top, spacing: spacing) {
// Decorative poster — hidden from VoiceOver (label covers it)
AsyncImage(url: film.posterURL) { image in
image.resizable().aspectRatio(2/3, contentMode: .fill)
} placeholder: {
RoundedRectangle(cornerRadius: 6)
.fill(Color.secondary.opacity(0.2))
}
.frame(width: posterWidth)
.clipShape(RoundedRectangle(cornerRadius: 6))
.accessibilityHidden(true) // decorative — the card label covers it
VStack(alignment: .leading, spacing: 4) {
Text(film.title)
.font(.headline)
Text("\(film.year) · \(film.director)")
.font(.subheadline)
.foregroundStyle(.secondary)
}
Spacer()
Button {
withAnimation(reduceMotion ? nil : .spring(bounce: 0.4)) {
isFavorited.toggle()
}
} label: {
Image(systemName: isFavorited ? "star.fill" : "star")
.foregroundStyle(isFavorited ? .yellow : .secondary)
.font(.title3)
.frame(minWidth: 44, minHeight: 44)
.contentShape(Rectangle())
}
.accessibilityLabel(isFavorited
? "Remove \(film.title) from favorites"
: "Add \(film.title) to favorites"
)
.accessibilityHint("Double-tap to toggle")
.accessibilityIdentifier("favoriteButton-\(film.id)")
}
.padding()
.background(Color.secondary.opacity(0.08))
.clipShape(RoundedRectangle(cornerRadius: 12))
// Card itself is navigable with a summary label
.accessibilityElement(children: .contain)
.accessibilityLabel(film.title)
.accessibilityValue(isFavorited ? "Favorited" : "Not favorited")
}
}
When to Use (and When Not To)
| Scenario | Recommendation |
|---|---|
| SF Symbol buttons with no visible text | Always add .accessibilityLabel with full context |
| Decorative images (logos, backgrounds) | .accessibilityHidden(true) |
| Card with multiple text labels | .accessibilityElement(children: .combine) to reduce navigation stops |
| Toggle or slider | Add .accessibilityValue to announce current state |
| Section headings | .accessibilityAddTraits(.isHeader) |
| Spring/bounce animations | Check accessibilityReduceMotion and use nil animation or opacity fade |
| Text that must always be readable | Font.body / Font.headline — never system(size:) |
| Icon sizes that should grow with text | @ScaledMetric(relativeTo:) |
| Supplementary detail (not read on every focus) | AccessibilityCustomContent (iOS 15+) |
| Multiple actions on one element | accessibilityAction(named:) for VoiceOver rotor actions |
Summary
- Accessibility labels tell VoiceOver what an element is; hints tell it what happens; values communicate current state. All three are necessary for interactive controls.
- SwiftUI’s semantic font styles (
.body,.headline, etc.) scale with Dynamic Type automatically. Use@ScaledMetricfor non-text dimensions. - Check
@Environment(\.accessibilityReduceMotion)and disable or replace movement-based animations for users who need it. .accessibilityElement(children: .combine)collapses multi-element cards into a single VoiceOver stop — critical for list performance.- Run Accessibility Inspector audits on every new screen — it catches missing labels, small targets, and contrast failures in under a minute.
Accessibility improvements often overlap with view architecture improvements — clean semantic grouping, proper labels, and Dynamic Type support all push you toward more composable, well-structured views. See Custom View Modifiers in SwiftUI for techniques to encapsulate accessibility annotations into reusable modifiers.