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

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:

TraitWhen to use
.isHeaderSection titles, screen headings
.isButtonCustom tappable areas that aren’t Button
.isSelectedCurrently selected item in a group
.isImageDefault on Image — remove for decorative images
.updatesFrequentlyLive data (timers, stock prices, progress)
.startsMediaSessionButtons that begin audio or video playback
.allowsDirectInteractionCustom 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:

  1. 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.
  2. 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.
  3. 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)

ScenarioRecommendation
SF Symbol buttons with no visible textAlways 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 sliderAdd .accessibilityValue to announce current state
Section headings.accessibilityAddTraits(.isHeader)
Spring/bounce animationsCheck accessibilityReduceMotion and use nil animation or opacity fade
Text that must always be readableFont.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 elementaccessibilityAction(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 @ScaledMetric for 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.