```html SwiftUI: How to Implement VoiceOver Support (iOS 17+, 2026)

How to implement VoiceOver support in SwiftUI

iOS 17+ Xcode 16+ Intermediate APIs: accessibilityElement, accessibilityLabel, accessibilityHint, accessibilityValue, accessibilityAddTraits, accessibilityAction Updated: May 11, 2026
TL;DR

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

  1. .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.
  2. .accessibilityElement(children: .combine) on the VStack. This collapses the two Text children into a single focus target. VoiceOver automatically concatenates their text values with a comma, producing "Neon Skyline, Andy Shauf."
  3. Dynamic .accessibilityLabel on the heart button. A bare Image(systemName:) inside a Button inherits 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.
  4. .accessibilityValue on 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.
  5. .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

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.

```