Skip to content
Commitwise
Menu
iOS Development

Accessibility

Make a SwiftUI app work with VoiceOver, Dynamic Type, Voice Control and other assistive settings, with meaningful labels and traits, grouped elements and custom actions, layouts that grow with text size, color and motion that respect user settings, and audits that catch regressions automatically.

intermediate

The core idea with one simple example.Step by step, with more examples and exercises.A cheat sheet: key APIs, gotchas and trade-offs.Internals, trade-offs and production pitfalls.

Turn on VoiceOver and open your app with your eyes closed. The first button says “heart fill”. The product list takes five swipes per product: the photo, the name, the price, the rating, the button. The checkout button is a styled HStack with a tap gesture, so VoiceOver can’t press it at all. Then set the text size to the largest setting: prices are cut off with ”…”, and the Pay button has moved off the screen.

Around one in four adults has some form of disability, and many more use larger text, reduced motion or Voice Control every day. In many countries accessibility is also a legal requirement. SwiftUI gives you most of it for free; this tutorial is about the parts it can’t guess.

New to some of these? Open what you need, or skip ahead.

Basics:What is VoiceOver?

VoiceOver is the screen reader built into iOS. It reads aloud what’s under the user’s finger, or the next element when they swipe right, and a double tap activates it. It describes each element by its label (“Add to cart”), its value (“3 items”) and its traits (“button”, “selected”, “heading”).

Basics:What is Dynamic Type?

Dynamic Type is the iOS text size setting. Apps that use the system text styles (.body, .headline, .title) grow and shrink their text with it, from extra small to accessibility sizes several times larger than the default.

Part Not started

Making sense to VoiceOver

Imagine describing your screen to a friend over the phone. You wouldn’t say “image, text, text, image, button” for every product; you’d say “Desk lamp, €34.99, four stars, add to cart”. VoiceOver needs the same help:

  • Every control needs a name. A heart icon means “Add to favorites”; say so.
  • Decoration should be silent. A background pattern or a divider isn’t worth a swipe.
  • Things that belong together should be read together. A product row is one thing, not five.
  • Tappable things must be buttons, so VoiceOver knows it can activate them.

SwiftUI derives accessibility from views: Text becomes a static text element, Button gets the button trait and its label from content, Image(systemName:) gets a generic name, Toggle exposes a value. Your job is the semantics it can’t infer: accessibilityLabel for icon-only controls, accessibilityHidden/Image(decorative:) for decoration, accessibilityElement(children: .combine/.ignore) to make rows one element, accessibilityValue for state, accessibilityAddTraits(.isHeader/.isSelected), accessibilityAction(named:) for secondary actions, accessibilitySortPriority for reading order, and accessibilityRepresentation when a custom control should behave like a standard one.

Before: Five stops per product, and an unnamed button

struct ProductRow: View {
    let product: Product
    let onFavorite: () -> Void

    var body: some View {
        HStack {
            AsyncImage(url: product.imageURL)
                .frame(width: 64, height: 64)
            VStack(alignment: .leading) {
                Text(product.name).font(.headline)
                Text(product.price.formatted(.currency(code: "EUR")))
                RatingStars(value: product.rating)          // five star images
            }
            Spacer()
            Button(action: onFavorite) {
                Image(systemName: product.isFavorite ? "heart.fill" : "heart")
            }
        }
    }
}

After: One element per product, with a named action

struct ProductRow: View {
    let product: Product
    let onFavorite: () -> Void

    var body: some View {
        HStack {
            AsyncImage(url: product.imageURL)
                .frame(width: 64, height: 64)
                .accessibilityHidden(true)                  // the name already says what it is
            VStack(alignment: .leading) {
                Text(product.name).font(.headline)
                Text(product.price.formatted(.currency(code: "EUR")))
                RatingStars(value: product.rating)
            }
            Spacer()
            Button(action: onFavorite) {
                Image(systemName: product.isFavorite ? "heart.fill" : "heart")
            }
            .accessibilityLabel(product.isFavorite ? "Remove from favorites" : "Add to favorites")
        }
        .accessibilityElement(children: .combine)           // read as one element
        .accessibilityLabel("\(product.name), \(product.price.formatted(.currency(code: "EUR"))), rated \(product.rating) out of 5")
        .accessibilityAction(named: product.isFavorite ? "Remove from favorites" : "Add to favorites", onFavorite)
    }
}
  • accessibilityHidden(true) on the photo: the product name already describes it.
  • .combine turns the row into one VoiceOver stop that reads “Desk lamp, €34.99, rated 4 out of 5”.
  • accessibilityAction(named:) keeps the favorite button reachable from the row: VoiceOver users swipe up or down to pick “Add to favorites” from the actions list.
  • The label changes with the state, so it always says what the button will do.
Spot the bugA checkout VoiceOver can't press

Tap the line with the bug.

HStack {
    Image(systemName: "creditcard")
    Text("Pay \(total.formatted(.currency(code: "EUR")))")
        .font(.headline)
}
.padding()
.frame(maxWidth: .infinity)
.background(.blue, in: .rect(cornerRadius: 12))
.foregroundStyle(.white)
.onTapGesture { checkout.pay() }

Predict the output

A product row has a photo, a name, a price, a rating made of five star images, and a heart button, with no accessibility modifiers. How many swipes does a VoiceOver user need to get past one product?

Exerciseeasy

Make a quantity stepper accessible

A custom stepper shows −, the number 3, and + as three separate views, each with a tap gesture on the symbols. Make it a single VoiceOver element that reads “Quantity, 3” and lets users change the value by swiping up and down, like a native stepper.

›

accessibilityElement(children: .ignore) replaces the children with one element. accessibilityAdjustableAction handles swipe up and down. accessibilityValue says the current number.

SolutionTry it yourself first
struct QuantityStepper: View {
    @Binding var quantity: Int
    let range: ClosedRange<Int>

    var body: some View {
        HStack(spacing: 16) {
            Button { quantity -= 1 } label: { Image(systemName: "minus") }
                .disabled(quantity <= range.lowerBound)
            Text("\(quantity)").monospacedDigit()
            Button { quantity += 1 } label: { Image(systemName: "plus") }
                .disabled(quantity >= range.upperBound)
        }
        .accessibilityElement(children: .ignore)
        .accessibilityLabel("Quantity")
        .accessibilityValue("\(quantity)")
        .accessibilityAdjustableAction { direction in
            switch direction {
            case .increment: if quantity < range.upperBound { quantity += 1 }
            case .decrement: if quantity > range.lowerBound { quantity -= 1 }
            @unknown default: break
            }
        }
    }
}

VoiceOver now reads “Quantity, 3, adjustable. Swipe up or down to adjust the value”, exactly like the system Stepper. (Using Stepper itself, with a custom style, would get this automatically.) The buttons are real Buttons too, so Voice Control and keyboard users can still press them.

Beyond labels

  • Headings: .accessibilityAddTraits(.isHeader) on section titles enables the rotor’s heading navigation.
  • Announcements: AccessibilityNotification.Announcement("Added to cart").post() for changes that happen away from focus; .accessibilityFocused to move focus after navigation or errors.
  • Rotors: accessibilityRotor for custom navigation (unread messages, errors in a form).
  • Voice Control uses the visible text: keep labels consistent with what’s on screen, and add accessibilityInputLabels for alternative spoken names.
  • Custom controls: accessibilityRepresentation { Slider(...) } gives a custom drawn control the full semantics of a native one.
  • Labels for icon-only controls, state-dependent; hide decoration; .combine/.ignore rows into one element; named actions instead of extra stops.
  • Real Buttons, never tap gestures on containers (no trait, no Voice Control or keyboard access).
  • Adjustable actions and values for steppers/sliders; accessibilityRepresentation for custom controls.
  • Headers, announcements, focus management, rotors, input labels for Voice Control.

Check this part

  1. What does VoiceOver read for a button showing only Image(systemName: "heart")?

  2. Why use Button instead of onTapGesture for tappable views?

  3. How do you keep a secondary action reachable after combining a row into one element?

VoiceOver describes each element by label, value and traits. Give icon-only controls a label, hide decorative images, combine the parts of a row into one element, use real Buttons (not tap gestures), and add custom actions instead of extra tap targets.

Part Not started

Text size, color and motion

Many more people use larger text than use VoiceOver. A layout designed only at the default size breaks in predictable ways at larger sizes: text cut off with ”…”, buttons pushed off screen, labels overlapping. The fixes are mostly about not fixing sizes:

  • Use text styles (.body, .headline) instead of fixed point sizes.
  • Let rows grow in height; avoid fixed heights for anything containing text.
  • At very large sizes, put side-by-side content under each other.

Text styles scale automatically; custom fonts scale with .custom(_:size:relativeTo:). Non-text dimensions (icon sizes, spacing, avatar sizes) scale with @ScaledMetric. dynamicTypeSize in the environment (with .isAccessibilitySize) or ViewThatFits switches layouts; .lineLimit(nil) plus fixedSize(horizontal: false, vertical: true) prevents truncation. Respect display settings via environment values: accessibilityReduceMotion, accessibilityReduceTransparency, accessibilityDifferentiateWithoutColor, colorSchemeContrast, legibilityWeight. Aim for WCAG contrast (4.5:1 for body text) in both light and dark mode, and never convey state by color alone.

struct OrderRow: View {
    let order: Order
    @Environment(\.dynamicTypeSize) private var typeSize
    @Environment(\.accessibilityDifferentiateWithoutColor) private var noColorOnly
    @ScaledMetric(relativeTo: .body) private var iconSize: CGFloat = 24

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

        layout {
            Image(systemName: "shippingbox")
                .font(.system(size: iconSize))
                .accessibilityHidden(true)
            VStack(alignment: .leading) {
                Text("Order \(order.number)").font(.headline)
                Text(order.placedAt, style: .date).font(.subheadline).foregroundStyle(.secondary)
            }
            Spacer(minLength: 0)
            StatusBadge(status: order.status, showIcon: noColorOnly)   // not color alone
        }
        .padding(.vertical, 8)
    }
}

struct StatusBadge: View {
    let status: OrderStatus
    var showIcon: Bool

    var body: some View {
        Label(status.title, systemImage: status.symbol)
            .labelStyle(showIcon ? AnyLabelStyle(.titleAndIcon) : AnyLabelStyle(.titleOnly))
            .font(.caption.weight(.semibold))
            .padding(.horizontal, 8).padding(.vertical, 4)
            .background(status.color.opacity(0.15), in: .capsule)
            .foregroundStyle(status.color)
    }
}
  • Text styles (.headline, .subheadline, .caption) follow the user’s text size.
  • @ScaledMetric grows the icon with the text, so it doesn’t look tiny next to large text.
  • At accessibility sizes, the row switches from side by side to stacked, so nothing is squeezed or cut off.
  • The status badge always has words (“Shipped”), not just a color, and adds an icon when the user asks for “Differentiate Without Color”.

Production puzzles

Spot the bugPrices cut off for the users who need them most

Tap the line with the bug.

HStack {
    Text(product.name)
        .font(.system(size: 16, weight: .semibold))
        .lineLimit(1)
    Spacer()
    Text(product.price.formatted(.currency(code: "EUR")))
        .font(.system(size: 16))
}
.frame(height: 44)

Motion and touch targets

  • Reduce Motion: replace large movements, parallax and auto-playing animations with fades (withAnimation(reduceMotion ? nil : .spring)), and never make information depend on an animation.
  • Touch targets: at least 44 × 44 points; .contentShape(.rect) and padding enlarge the hit area of small icons without changing their look.
  • Timeouts: avoid content that disappears on its own (toasts with actions) or give users enough time; VoiceOver users take longer to reach it.
  • Text styles or relativeTo: custom fonts; @ScaledMetric for icons and spacing; no fixed heights for text (minHeight only); ViewThatFits or isAccessibilitySize layout switches.
  • Contrast ≥ 4.5:1 in both appearances; never color alone (accessibilityDifferentiateWithoutColor).
  • Reduce Motion, Reduce Transparency, Increase Contrast, Bold Text via environment values.
  • 44 × 44 pt targets; no essential timeouts.

Check this part

  1. Why use .font(.headline) instead of .font(.system(size: 17))?

  2. A status is shown only as a green or red dot. What's the problem?

  3. How should an icon's size follow Dynamic Type?

Use text styles so text follows Dynamic Type, let layouts grow and switch from horizontal to vertical at accessibility sizes, scale custom dimensions with @ScaledMetric, never truncate essential text, keep contrast high, and respect Reduce Motion and other display settings.

Part Not started

Checking it

The best test is to use the app the way your users do. Before a release, try the main flows (sign in, find a product, pay) with:

  1. VoiceOver on (triple-click the side button if you set the Accessibility Shortcut). Can you complete the flow without looking? Is every element read sensibly?
  2. The largest text size (Settings › Accessibility › Display & Text Size › Larger Text). Is anything cut off or unreachable?
  3. Accessibility Inspector (Xcode › Open Developer Tool) to see the label, value and traits of any element, and run its audit.

Xcode 15+ adds XCUIApplication.performAccessibilityAudit(for:_:) to UI tests: it checks the current screen for missing descriptions, small hit regions, clipped or non-scaling text, low contrast, element detection problems and trait issues, and fails the test with the offending element. Run it on key screens in CI, filter known issues explicitly in the handler, and pair it with snapshot tests at large type sizes. Automated audits catch maybe a third of real problems; manual VoiceOver passes on critical flows and feedback from users of assistive technology catch the rest.

final class AccessibilityAuditTests: XCTestCase {
    @MainActor
    func testCheckoutScreenPassesAudit() throws {
        let app = XCUIApplication()
        app.launchArguments = ["-uiTesting", "-UIPreferredContentSizeCategoryName", "UICTContentSizeCategoryAccessibilityXXXL"]
        app.launch()
        app.buttons["tab.cart"].tap()

        try app.performAccessibilityAudit(for: .all) { issue in
            // Return true to ignore a known, tracked issue; everything else fails the test
            issue.auditType == .contrast && issue.element?.identifier == "promo.banner"
        }
    }
}
  • -UIPreferredContentSizeCategoryName launches the app at the largest accessibility text size, so the audit also finds clipped text.
  • performAccessibilityAudit fails the test for problems like a button without a label or a target smaller than 44 points.
  • Ignoring an issue has to be explicit and specific, so new problems still fail.
  • Manual passes with VoiceOver and largest text sizes on critical flows; Accessibility Inspector.
  • performAccessibilityAudit(for:) in UI tests on key screens, launched at accessibility type sizes; explicit, narrow ignores.
  • Snapshot tests at large sizes; automated checks catch only part of the problems.

Check this part

  1. What's the most reliable accessibility test?

  2. What does performAccessibilityAudit() check?

Test accessibility like any feature: use the app with VoiceOver and the largest text size before release, inspect with Accessibility Inspector, and run XCUIApplication.performAccessibilityAudit() in UI tests to catch missing labels, small targets, clipped text and low contrast automatically.

Key takeaways

  • Name every icon-only control, hide decoration, and combine rows into one VoiceOver element.
  • Use real buttons, never tap gestures on containers.
  • Use text styles, let layouts grow and stack at large sizes, and never rely on color alone.
  • Test main flows with VoiceOver and the largest text size before every release.
  • Semantics SwiftUI can’t infer: labels, values, traits, actions, adjustable actions, representations.
  • Scaled metrics and adaptive layouts; contrast and motion settings from the environment.
  • Accessibility audits in UI tests, plus manual passes and real user feedback.
  • Accessibility is a feature with requirements and regressions, not a final polish.

Finished this page?Nice work, this page is complete!

Mark it as done to track your progress. It's saved in this browser, no account needed.

Its cards are now in your Bites review ⚡
Esc

Type to search the whole site.