Start a project

Article · iOS · SwiftUI · Accessibility · 8 min read

Accessible iOS apps in SwiftUI: VoiceOver, Dynamic Type and beyond

Accessibility is often treated as a polish task for the end of a project. In SwiftUI it’s far cheaper to build in as you go, because the framework already does much of the work: standard controls come with labels, traits and Dynamic Type support. The gaps appear where we build custom things. This guide covers those gaps, how to test for them, and why it matters commercially as well as ethically.

On this page 8 sections
  1. Why it’s worth doing properly
  2. VoiceOver: labels, values, traits and grouping
  3. Dynamic Type: let the layout breathe
  4. Colour, contrast and motion
  5. Testing: by hand and automatically
  6. A checklist for every new screen
  7. Voice Control, focus and announcements
  8. Key takeaways

Why it’s worth doing properly

People use the accessibility features on their iPhones far more widely than “accessibility” suggests. Larger text, bold text, Reduce Motion and VoiceOver are used by people with permanent disabilities, by people with temporary ones such as a broken wrist, and by people in situations that limit them, such as bright sunlight or holding a child in one arm.

There are also concrete business reasons:

Apple’s Human Interface Guidelines on accessibility are the design reference.

VoiceOver: labels, values, traits and grouping

VoiceOver reads each accessibility element aloud: its label (what it is), value (its current state), traits (button, header, selected) and hint (what happens if you activate it). SwiftUI fills these in for standard controls. Custom views need help.

Images and icon buttons need a label. A button with only an SF Symbol gets a guessed label from the symbol name, which is often wrong:

Button {
    toggleFavourite()
} label: {
    Image(systemName: isFavourite ? "heart.fill" : "heart")
}
.accessibilityLabel(isFavourite ? "Remove from favourites" : "Add to favourites")

Purely decorative images should be hidden with Image(decorative:) or .accessibilityHidden(true), so VoiceOver users don’t have to swipe past them.

Rows made of several pieces should usually read as one element:

struct OrderRow: View {
    let order: Order
    let onTrack: () -> Void

    var body: some View {
        HStack {
            Image(systemName: order.status.symbol)
                .accessibilityHidden(true)
            VStack(alignment: .leading) {
                Text("Order \(order.number)")
                Text(order.status.label).foregroundStyle(.secondary)
            }
            Spacer()
            Text(order.total, format: .currency(code: "GBP"))
        }
        .accessibilityElement(children: .combine)
        .accessibilityAction(named: "Track parcel", onTrack)
    }
}

.combine merges the texts into one element, so VoiceOver reads “Order 1042, Dispatched, £24.00” in a single swipe instead of four. The custom action puts “Track parcel” in VoiceOver’s actions rotor, so the row doesn’t need a tiny extra button.

Headings let VoiceOver users jump between sections with the rotor. Mark section titles that aren’t already headers:

Text("Upcoming")
    .font(.title2.bold())
    .accessibilityAddTraits(.isHeader)

Custom controls should present themselves as the standard control they behave like. accessibilityRepresentation lets a hand-drawn slider be read and adjusted exactly like a system Slider:

struct RatingDial: View {
    @Binding var rating: Double

    var body: some View {
        DialShape(value: rating)
            .gesture(dragGesture)
            .accessibilityRepresentation {
                Slider(value: $rating, in: 0...5, step: 1) {
                    Text("Rating")
                }
            }
    }
}

Dynamic Type: let the layout breathe

Dynamic Type lets people choose their text size, and the largest accessibility sizes are very large. Three habits handle nearly all of it:

  1. Use text styles, such as .body, .headline and .caption, rather than fixed point sizes. For a custom font, .font(.custom("Brand", size: 17, relativeTo: .body)) still scales.
  2. Scale non-text sizes with @ScaledMetric, so icons and spacing grow with the text:
struct StatTile: View {
    let title: String
    let value: String
    @ScaledMetric(relativeTo: .body) private var iconSize = 24.0

    var body: some View {
        HStack {
            Image(systemName: "chart.bar.fill")
                .resizable()
                .scaledToFit()
                .frame(width: iconSize, height: iconSize)
                .accessibilityHidden(true)
            VStack(alignment: .leading) {
                Text(title).font(.subheadline)
                Text(value).font(.title3.bold())
            }
        }
    }
}
  1. Switch layouts at accessibility sizes. Side-by-side content that fits at the default size often gets squashed at the largest ones. Stack it instead:
struct ProductHeader: View {
    @Environment(\.dynamicTypeSize) private var typeSize
    let product: Product

    var body: some View {
        let layout = typeSize.isAccessibilitySize
            ? AnyLayout(VStackLayout(alignment: .leading, spacing: 12))
            : AnyLayout(HStackLayout(alignment: .top, spacing: 16))

        layout {
            ProductImage(product: product)
            ProductSummary(product: product)
        }
    }
}

ViewThatFits is an alternative when you’d rather SwiftUI pick the first layout that fits the space. Avoid .lineLimit(1) on anything important; let text wrap.

Colour, contrast and motion

Reduce Motion is the one most often ignored. Large zooms, parallax and spinning transitions can make some people feel unwell:

struct ExpandingCard: View {
    @Environment(\.accessibilityReduceMotion) private var reduceMotion
    @State private var isExpanded = false

    var body: some View {
        CardContent(isExpanded: isExpanded)
            .onTapGesture { isExpanded.toggle() }
            .animation(reduceMotion ? .easeInOut(duration: 0.15) : .spring(duration: 0.45), value: isExpanded)
    }
}

The reduced version still animates, but with a short fade-like change rather than movement and bounce.

Testing: by hand and automatically

Use the features yourself. Turn on VoiceOver (Settings › Accessibility › VoiceOver, or set the Accessibility Shortcut to triple-click the side button) and complete your app’s three most important tasks with the screen curtain on. Then set text to the largest accessibility size and do the same. Twenty minutes of this finds more than any checklist.

Accessibility Inspector, in Xcode’s Developer Tools menu, shows each element’s label, value and traits, and its audit finds missing labels, small hit areas, clipped text and contrast problems.

Automated audits in UI tests catch regressions. Since Xcode 15, XCUIApplication can run the same audit:

import XCTest

final class AccessibilityAuditTests: XCTestCase {
    @MainActor
    func testHomeScreenPassesAudit() throws {
        let app = XCUIApplication()
        app.launch()
        try app.performAccessibilityAudit()
    }

    @MainActor
    func testCheckoutPassesAuditIgnoringKnownIssue() throws {
        let app = XCUIApplication()
        app.launch()
        app.buttons["Basket"].tap()
        try app.performAccessibilityAudit(for: [.dynamicType, .contrast, .hitRegion, .sufficientElementDescription]) { issue in
            // Return true to ignore an issue you've reviewed and accepted.
            issue.element?.identifier == "promo-banner"
        }
    }
}

The automated audit won’t tell you whether a flow makes sense when read aloud, so it complements manual testing rather than replacing it.

A checklist for every new screen

The Apple Watch has its own constraints; Designing for the Apple Watch covers accessibility on the wrist.

Voice Control, focus and announcements

VoiceOver isn’t the only assistive technology that reads your labels. Voice Control lets people operate the phone by speaking the names of buttons, so a label also has to be something people would naturally say. When several phrases are reasonable, list them:

Button {
    compose()
} label: {
    Image(systemName: "square.and.pencil")
}
.accessibilityLabel("New message")
.accessibilityInputLabels(["New message", "Compose", "Write"])

Focus matters when something appears that the user needs to know about, such as a validation error after tapping “Save”. Sighted users see the red text; VoiceOver users hear nothing unless you move focus to it:

struct SignUpForm: View {
    @State private var email = ""
    @State private var errorMessage: String?
    @AccessibilityFocusState private var errorFocused: Bool

    var body: some View {
        Form {
            TextField("Email", text: $email)
                .textContentType(.emailAddress)
                .keyboardType(.emailAddress)
            if let errorMessage {
                Text(errorMessage)
                    .foregroundStyle(.red)
                    .accessibilityFocused($errorFocused)
            }
            Button("Create account", action: submit)
        }
    }

    private func submit() {
        guard email.contains("@") else {
            errorMessage = "Enter an email address, like name@example.com."
            errorFocused = true
            return
        }
        errorMessage = nil
    }
}

For changes that don’t need focus, such as “Saved” after an autosave, post an announcement instead: AccessibilityNotification.Announcement("Saved").post(). Use announcements sparingly; a running commentary is as tiring to listen to as it would be to read.

Switch Control and Full Keyboard Access move through the same accessibility elements in order. If VoiceOver’s order through a screen makes sense and every action is reachable as a button or a custom action, these usually work too. Test them once on each key screen.

Key takeaways

If you need an accessibility review of an existing app, or want to make sure a new one can honestly display Accessibility Nutrition Labels at launch, get in touch.

Written by Lee Johnson. Published 30 October 2026.

All articles · Swift A–Z · Apps

Questions, or an app in mind?

Let's bring your ideas to life. Whether you're envisioning a game-changing Apple Watch app or a transformative iOS experience, I'm here to make it happen.

Start a project