5. VoiceOver in practice

Status: draft

Chapter goal

VoiceOver reads semantics, not pixels. Each element gives it a label, a value, traits, sometimes a hint, a position in the order and a set of actions. This chapter treats each of these as an implementation decision and checks it against a real VoiceOver transcript from the Leaf demo.

The screen

A product card in the Leaf demo store: tea illustration, name, weight, price, stock status, Add to cart button.

Without any accessibility work, VoiceOver splits the card into separate stops: decorative image, name, weight, price, "In stock", button. That's six swipes to reach one action, and the price and the name are never heard together.

What the user needs from this card is three answers: what is it, how much is it, how do I buy it. Two stops are enough.

VoiceOver outlines the jasmine tea description. The system Caption Panel shows the currently spoken fragment, beginning with the product name.
VoiceOver focus on the grouped product description. The Caption Panel shows the current fragment. The full utterance is in the linked transcript. Captured on iPhone 17 Pro Max, iOS 27.0.1.
VoiceOver focuses Add jasmine tea to cart and displays its button announcement in the system Caption Panel.
The purchase action is a separate element with the native button trait. Captured on iPhone 17 Pro Max, iOS 27.0.1.

Actual transcript for these two stops, from all-evidence.json:

Jasmine tea, green, 100 grams 2 dollars and 50 cents, in stock
Add jasmine tea to cart Button

How to see what VoiceOver says

Listening is slow, and you can't paste it into a bug report. Turn on the Caption Panel: Settings → Accessibility → VoiceOver → Caption Panel. It shows the text VoiceOver is speaking at the bottom of the screen. It has nothing to do with video subtitles or Live Captions. See Apple: Change your VoiceOver settings.

The actual VoiceOver settings page with its system controls.
Settings → Accessibility → VoiceOver. Captured on iPhone 17 Pro Max, iOS 27.0.1.
The actual Caption Panel switch is on in VoiceOver settings.
Caption Panel on. It shows what VoiceOver says. It is not a video subtitle setting. Captured on iPhone 17 Pro Max, iOS 27.0.1.

All screenshots in this chapter were captured on iOS 27.0.1, one version above the book baseline. The APIs used here have been stable since iOS 13–17.

The parts of an element

Part Answers Example Who sets it
Label What is it? "Volume" You, or the control's text
Value What state is it in? "50 percent" You, or the control
Traits What kind of thing, how does it behave? button, adjustable, selected, heading Native controls set them; custom views need them added
Hint What happens if I activate it? Only when not obvious "Swipe up or down with one finger to adjust the value." You; system controls add their own
Order When do I hear it? after the price, before Pay Layout, or accessibilitySortPriority
Actions What else can I do here? Reorder, Delete accessibilityAction, custom actions

The native slider in the demo shows four of them in one utterance:

Volume 50 percent adjustable Swipe up or down with one finger to adjust the value.
label  value      trait      hint (added by the system)
VoiceOver focuses the Volume slider at 50 percent, with its output displayed in the system Caption Panel.
A native Slider with accessibilityLabel("Volume") and accessibilityValue("50 percent"). The trait and the hint come from the system. Captured on iPhone 17 Pro Max, iOS 27.0.1.

Constraints

  • The role never goes in the label. "Save button" plus the button trait is read as "Save button, button".
  • accessibilityIdentifier is not a label. It's for UI tests only. VoiceOver never reads it.
  • A hint is optional. Users can turn hints off in VoiceOver settings. Anything critical goes into the label or the value.
  • Announcements are for task results. Not for animations, not for every state change.

Decision 1: Group the description, keep the action separate

Verdict: one element for the description, one for each action. Not one element for the whole card.

Why: six stops are noise (Label Soup). One stop for the whole card hides the button inside it (Over Grouped Control). Two stops give the full description, then the action.

Code from the demo:

teaDescription()
    .accessibilityElement(children: .ignore)
    .accessibilityLabel("Jasmine tea, green, 100 grams")
    .accessibilityValue("2 dollars and 50 cents, in stock")

Button(inCart ? "Added to cart" : "Add to cart") { inCart.toggle() }
    .accessibilityLabel("Add jasmine tea to cart")
    .accessibilityInputLabels(["Add to cart", "Add jasmine tea to cart"])
    .accessibilityValue(inCart ? "Added" : "")

illustration
    .accessibilityHidden(true)

.ignore drops the children and uses only the label and value you write. .combine would join the children's text automatically. Use .combine when the visible text is already good. Use .ignore when you need different wording, like "100 grams" instead of "100 g".

Check: two swipes from the card title to the next card. The transcript matches the two lines above.

Why the input labels: Voice Control

The button shows "Add to cart" and has the label "Add jasmine tea to cart". A Voice Control user reads "Add to cart" on screen and says "Tap Add to cart". That phrase is not a contiguous part of the label, so without help the command may not match. WCAG calls this Label in Name (2.5.3).

Two ways to keep it: start the label with the visible text, or give Voice Control extra names with accessibilityInputLabels, as the code above does. The first input label is the one Voice Control shows in its overlay. VoiceOver still reads accessibilityLabel. More in Chapter 8.

Decision 2: Name in the label, state in the value

Verdict: the label stays the same, the value changes.

Why: when the state is in the label, every change rewrites the name. Voice Control loses the command, and VoiceOver users hear a different element each time. Value also localizes separately from the name.

Toggle("Captions", isOn: $captions)

A native Toggle already has a value: VoiceOver reads "Captions, on" or "off". We don't add accessibilityValue to it. Custom views need it set explicitly:

CaptionsSwitch(isOn: $captions)
    .accessibilityLabel("Captions")
    .accessibilityValue(captions ? "On" : "Off")
    .accessibilityAddTraits(.isButton)

Check: toggle it twice. The label stays the same, only the value changes.

Decision 3: Mark headings

Verdict: every visual section title gets the header trait.

Why: VoiceOver users move between headings with the rotor. Without the trait, a 20-element screen means 20 swipes.

The demo had this bug. Before the fix, the transcript read the screen title as plain text:

COLLECTION NO. 04
Time for tea

No "Heading", and the rotor's Headings mode found nothing. After the fix, the device transcript says "Time for tea Heading". The fix is one line:

Text("Time for tea")
    .font(.largeTitle.bold())
    .accessibilityAddTraits(.isHeader)

Check: set the rotor to Headings and swipe down. Each section title is reached in one swipe.

Decision 4: Secondary actions go into custom actions

Verdict: in a repeated row, the main action is the row itself. Secondary actions go into the actions list.

Why: a list of 20 orders with Reorder, Share and Delete buttons on each row means 80 stops. With custom actions it's 20. The user swipes up or down on a row to choose an action. Switch Control shows the same actions in its menu. This is also the alternative to swipe-to-delete for users who can't swipe.

OrderRow(order: order)
    .accessibilityElement(children: .combine)
    .accessibilityAction(named: "Reorder") { reorder(order) }
    .accessibilityAction(named: "Delete") { delete(order) }

UIKit:

cell.isAccessibilityElement = true
cell.accessibilityLabel = "Order 42"
cell.accessibilityValue = "Delivered, November 12, 7 dollars 50 cents"
cell.accessibilityCustomActions = [
    UIAccessibilityCustomAction(name: "Reorder") { _ in
        reorder(order)
        return true
    }
]

Limit: custom actions can't be discovered by looking at the screen. If an action is primary or dangerous, keep it as a visible button. The demo's order history keeps Reorder visible for exactly this reason.

Check: on a row, VoiceOver says "Actions available". Swiping down lists Reorder and Delete.

Decision 5: Order follows the task

Verdict: VoiceOver order matches what the user does on the screen: product, address, payment. When the layout order is wrong, fix the layout first. accessibilitySortPriority comes second.

Why: VoiceOver reads top-left to bottom-right. Overlays, ZStack and custom layouts break this. A Pay button read before the address means the user pays before checking where it goes.

VoiceOver reaches the payment action after the product summary and delivery address.
VoiceOver order on checkout: product, address, payment. The UI test swipes through the real order and records each utterance. Captured on iPhone 17 Pro Max, iOS 27.0.1.

If a fix in layout isn't possible:

VStack {
    summary.accessibilitySortPriority(3)
    address.accessibilitySortPriority(2)
    payButton.accessibilitySortPriority(1)
}
.accessibilityElement(children: .contain)

Higher priority is read first. Priority only works among siblings inside one container. That's why the container is .contain.

Check: swipe from the top. The order matches the task order written in the ticket.

Decision 6: Announce the result, not the process

Verdict: one announcement when a task completes. Nothing during it.

AccessibilityNotification.Announcement("Added to cart. Cart has 3 items.").post()

Why: the user pressed Add and focus stayed on the button. Without the announcement, they would have to find the cart badge to confirm the action worked. With an announcement for every animation and counter tick, speech is constantly interrupted (Announcement Spam).

The announcement goes alongside the visual change, it doesn't replace it. The badge updates, the button value changes to "Added". The announcement is a shortcut for VoiceOver users, not the only record.

Check: add an item. VoiceOver says it once, and the button now reads "Added".

Bug report format

A transcript turns a vague bug into a specific one:

Task:     add tea to cart
Expected: Jasmine tea, green, 100 grams, 2 dollars and 50 cents, in stock
          Add jasmine tea to cart, button
Actual:   image
          Jasmine tea
          Green tea · 100 g
          $2.50
          In stock
          Add to cart, button

Expected is written before implementation. Actual comes from the Caption Panel or the UI test transcript. AI can compare the two and suggest a fix. Confirming the fix still takes a device.

What we don't do

  • We don't fill in every hint. The system adds hints for native controls, and users can turn hints off.
  • We don't hide visible actions in custom actions.
  • We don't post announcements to cover up wrong focus. Focus problems are fixed with focus (Chapter 4).

Platform notes

  • SwiftUI: accessibilityElement(children:), accessibilityLabel, accessibilityValue, accessibilityAddTraits, accessibilityAction(named:), accessibilitySortPriority. Chapter 10.
  • UIKit: isAccessibilityElement, accessibilityElements for order, accessibilityCustomActions, UIAccessibility.post. Chapter 11.
  • AppKit: the same parts under different names: role, label, value, help. Keyboard focus comes first. Chapter 12.
  1. Label Soup: VoiceOver reads fragments.
  2. Over Grouped Control: grouping swallowed the action.
  3. Announcement Spam: the app speaks too often.
  4. Right Sized Grouping: between noise and hidden controls.
  5. Accessible Card: description plus separate action.
  6. Rotor and custom actions: guide.
  7. VoiceOver output debugging: guide.
  8. VoiceOver gestures guide: manual walkthrough.

Chapter takeaway

Write the expected VoiceOver output before the code, then compare it with the transcript. Fewer stops, state in the value, headings marked, secondary actions in the actions list.

Sources

  1. Apple Support: Turn on and practice VoiceOver on iPhone
  2. Apple Support: Use VoiceOver in apps on iPhone
  3. Apple Developer: SwiftUI accessibility modifiers
  4. Apple Developer: accessibilityElement(children:)
  5. Apple Developer: accessibilityInputLabels
  6. Apple Developer: UIAccessibilityCustomAction
  7. Apple Developer: AccessibilityNotification
  8. W3C: Understanding SC 2.5.3 Label in Name