How to implement VoiceOver support in SwiftUI
Apply .accessibilityElement(children:) to group compound views, then layer on
.accessibilityLabel, .accessibilityHint, and
.accessibilityAddTraits so VoiceOver reads your UI correctly with zero guesswork.
// Minimal: label + hint + button trait
Button(action: favorite) {
Image(systemName: isFavorited ? "heart.fill" : "heart")
}
.accessibilityLabel(isFavorited ? "Remove from favorites" : "Add to favorites")
.accessibilityHint("Double-tap to toggle")
.accessibilityAddTraits(.isButton)
// Group a card so VoiceOver reads it as one element
VStack(alignment: .leading) {
Text(song.title).font(.headline)
Text(song.artist).font(.subheadline)
}
.accessibilityElement(children: .combine)
Full implementation
The example below builds a music track row — a common compound view with an image, two text labels, a favorite button, and a playback progress bar.
Without intervention, VoiceOver would focus each child element individually and read nonsensical fragments.
We use accessibilityElement(children: .combine) on the info stack,
explicit labels on interactive controls, accessibilityValue to surface the progress percentage,
and a custom accessibilityAction so rotor users can seek forward without needing to reach the slider.
import SwiftUI
struct Track: Identifiable {
let id = UUID()
var title: String
var artist: String
var artworkSystemName: String
var isFavorited: Bool
var progress: Double // 0.0 – 1.0
}
struct TrackRow: View {
let track: Track
var onFavorite: () -> Void
var onSeekForward: () -> Void
var body: some View {
HStack(spacing: 14) {
// Artwork — decorative, hidden from VoiceOver
Image(systemName: track.artworkSystemName)
.resizable()
.scaledToFit()
.frame(width: 52, height: 52)
.clipShape(RoundedRectangle(cornerRadius: 8))
.accessibilityHidden(true)
// Text info — combine so VoiceOver reads both lines as one
VStack(alignment: .leading, spacing: 2) {
Text(track.title)
.font(.headline)
Text(track.artist)
.font(.subheadline)
.foregroundStyle(.secondary)
}
.accessibilityElement(children: .combine)
// .combine builds "Song Title, Artist Name" automatically
Spacer()
// Favorite button with clear label reflecting current state
Button(action: onFavorite) {
Image(systemName: track.isFavorited ? "heart.fill" : "heart")
.foregroundStyle(track.isFavorited ? .red : .secondary)
}
.buttonStyle(.plain)
.accessibilityLabel(
track.isFavorited ? "Remove \(track.title) from favorites"
: "Add \(track.title) to favorites"
)
.accessibilityHint("Double-tap to toggle")
}
.padding(.vertical, 8)
// Progress bar row below
.overlay(alignment: .bottom) {
GeometryReader { geo in
Capsule()
.fill(Color.accentColor.opacity(0.25))
.frame(width: geo.size.width, height: 3)
.overlay(alignment: .leading) {
Capsule()
.fill(Color.accentColor)
.frame(width: geo.size.width * track.progress, height: 3)
}
}
.frame(height: 3)
// Express progress to VoiceOver as a percentage value
.accessibilityLabel("Playback progress")
.accessibilityValue("\(Int(track.progress * 100)) percent")
.accessibilityAddTraits(.updatesFrequently)
}
// Expose a rotor action so VoiceOver users can seek without the slider
.accessibilityAction(named: "Seek forward 15 seconds", action: onSeekForward)
}
}
// MARK: - Preview
#Preview {
List {
TrackRow(
track: Track(
title: "Neon Skyline",
artist: "Andy Shauf",
artworkSystemName: "music.note",
isFavorited: true,
progress: 0.42
),
onFavorite: { },
onSeekForward: { }
)
TrackRow(
track: Track(
title: "Motion Picture Soundtrack",
artist: "Radiohead",
artworkSystemName: "waveform",
isFavorited: false,
progress: 0.08
),
onFavorite: { },
onSeekForward: { }
)
}
.listStyle(.plain)
}
How it works
-
.accessibilityHidden(true)on artwork. The album art image carries no semantic meaning that isn't conveyed by the track title and artist. Hiding it prevents VoiceOver from announcing "image" and wasting the user's time mid-list. -
.accessibilityElement(children: .combine)on the VStack. This collapses the twoTextchildren into a single focus target. VoiceOver automatically concatenates their text values with a comma, producing "Neon Skyline, Andy Shauf." -
Dynamic
.accessibilityLabelon the heart button. A bareImage(systemName:)inside aButtoninherits SF Symbols' built-in description ("heart"), but that description doesn't reflect state or context. The computed string includes the track title so users in a long list always know which item they're acting on. -
.accessibilityValueon the progress capsule. Custom-drawn progress indicators are opaque to VoiceOver. Applying.accessibilityValue("42 percent")(paired with.updatesFrequently) tells VoiceOver both the current value and that it changes dynamically during playback. -
.accessibilityAction(named:)for seek-forward. VoiceOver users can reveal custom actions via the rotor (swipe up/down while focused). This surfaces a "Seek forward 15 seconds" action without any visual change to the row, giving screen-reader users feature parity with sighted users who tap-and-drag the progress bar.
Variants
Sorting a container's VoiceOver reading order
When SwiftUI's layout order doesn't match the logical reading order (e.g. a Z-stack badge floating over a card), use
.accessibilitySortPriority to force the sequence VoiceOver follows.
ZStack(alignment: .topTrailing) {
// Card content — read first
CardView(item: item)
.accessibilitySortPriority(1)
// Unread badge — read second
if item.unreadCount > 0 {
Circle()
.fill(.red)
.frame(width: 20, height: 20)
.overlay {
Text("\(item.unreadCount)")
.font(.caption2.bold())
.foregroundStyle(.white)
}
.accessibilityLabel("\(item.unreadCount) unread")
.accessibilitySortPriority(0)
}
}
Announcing live region updates
For status text that changes without user interaction (e.g. a sync status banner or network indicator), apply
.accessibilityAddTraits(.updatesFrequently) combined with a
@State-driven label. When the state changes VoiceOver will interrupt and announce the new value automatically — no imperative UIAccessibility.post(notification:) bridge required in pure SwiftUI.
Common pitfalls
-
Using
.accessibilityElement(children: .ignore)by accident..ignorehides the container and all children from VoiceOver. Use.combinewhen you want children merged into the parent, and.containwhen you want children focusable but grouped under a heading. Only use.ignorefor genuinely decorative containers. -
Hardcoded labels that don't reflect state.
A label like
"Heart button"is technically correct but unhelpful. Always write labels that describe the action and its current state — VoiceOver reads the label before the hint, so pack meaning there first. - Forgetting to test with VoiceOver actually enabled. The Accessibility Inspector in Xcode can flag missing labels, but it can't simulate the VoiceOver focus traversal order or the feel of one-finger swipe navigation. Always run through your critical flows on a real device with VoiceOver on before shipping — especially anything involving custom gestures, drag handles, or multi-column layouts.
Prompt this with Claude Code
When using Soarias or Claude Code directly to implement this:
Implement voiceover support in SwiftUI for iOS 17+. Use accessibilityElement(children:), accessibilityLabel, accessibilityHint, accessibilityValue, accessibilityAddTraits, and accessibilityAction. Make it accessible (VoiceOver labels). Add a #Preview with realistic sample data.
In Soarias' Build phase, paste this prompt directly into the Claude Code panel after scaffolding your view — the generated code will be wired into your existing SwiftData models with correct state-dependent labels ready for App Store review.
Related
FAQ
Does this work on iOS 16?
Almost entirely, yes. The accessibilityElement, accessibilityLabel, accessibilityHint, and accessibilityAddTraits modifiers are available back to SwiftUI's introduction. The main iOS 17+ delta is the improved #Preview macro and some refinements to accessibilityRotorEntry. If you need to target iOS 16, the patterns above compile fine — just use PreviewProvider instead.
How do I handle custom drawing (Canvas, drawRect) and VoiceOver?
SwiftUI's Canvas is completely opaque to the accessibility system. Wrap it in a ZStack with a zero-opacity overlay view that carries your accessibilityLabel, accessibilityValue, and any traits. Alternatively, use the Canvas { context, size in … }.accessibilityLabel(…) modifier directly on the Canvas itself — it accepts accessibility modifiers and forwards them to the VoiceOver engine even though the pixel content is custom-drawn.
What's the UIKit equivalent?
In UIKit you set isAccessibilityElement, accessibilityLabel, accessibilityHint, accessibilityValue, and accessibilityTraits directly on UIView instances, and use UIAccessibilityCustomAction for rotor actions. SwiftUI's modifier API maps 1-to-1 to these properties; the runtime bridges them under the hood, so a UIViewRepresentable wrapper can mix both without conflict.
Last reviewed: 2026-05-11 by the Soarias team.