---
name: ios-accessibility
description: "VoiceOver, Voice Control, Switch Control and Full Keyboard Access in SwiftUI, UIKit and AppKit: labels, traits, grouping, focus restore, Dynamic Type, Large Content Viewer, rotors, custom actions, accessibility checks and Nutrition Labels. Use when implementing, reviewing or auditing accessibility (a11y). Not for keyboard or directional focus mechanics: use hig-inputs."
metadata:
  source: multi-agent-pipeline
---

# iOS Accessibility

Whatever appears on screen must also work for people who navigate with
VoiceOver, speak commands through Voice Control, scan with Switch Control, drive
a hardware keyboard through Full Keyboard Access, or rely on any other
assistive technology. Platforms: iOS, iPadOS and macOS. Frameworks: SwiftUI,
UIKit and AppKit.

References:

- [Code patterns](references/a11y-patterns.md): worked examples of the
  modifiers in both UI frameworks, grouping, custom and adjustable controls, custom actions, rotors,
  Dynamic Type, AppKit, Voice Control, Switch Control, Full Keyboard Access,
  XCTest checks.
- [Nutrition labels](references/nutrition-labels.md): the current App Store
  label categories and what passes or fails each one.
- [Media](references/media-accessibility.md): captions, SDH, audio
  descriptions and `AVMediaCharacteristic`.
- [Large content and appearance](references/large-content-and-appearance.md):
  the Large Content Viewer for controls that cannot grow, and how Liquid
  Glass reacts to Reduce Transparency, Increase Contrast and Reduce Motion.

## Ground rules

1. Anything interactive has an accessible label. No visible text means you add
   `.accessibilityLabel`.
2. Custom controls gain traits with `.accessibilityAddTraits`. Never replace
   the trait set wholesale.
3. A two-state custom control (star, pin, favourite) should be a real
   `Toggle`. If it cannot be, add `.accessibilityAddTraits(.isToggle)`
   (iOS 17+) and report on/off as the value; the label never says what kind of
   control it is.
4. Custom adjustable controls, such as a quantity stepper, implement
   `.accessibilityAdjustableAction`. In UIKit they also carry the `.adjustable`
   trait.
5. Purely decorative images are hidden from assistive technologies.
6. When a sheet or dialog closes, VoiceOver focus goes back to whatever
   opened it.
7. Nothing tappable is smaller than 44x44 points.
8. Dynamic Type works everywhere: system text styles, `@ScaledMetric`, layouts
   that adapt.
9. Color is never the only signal; pair it with text or an icon.
10. Honour Increase Contrast, Bold Text, Reduce Transparency and Reduce Motion.

## What VoiceOver says, and in which order

VoiceOver speaks an element's parts in a fixed order you cannot change:
**label, value, trait, hint**. Write each piece knowing what comes before and
after it, so the sentence as a whole makes sense ("Volume, 60 percent,
adjustable, swipe up or down to adjust").

Modifier examples for labels, hints, traits, grouping, custom and adjustable
controls and custom actions: [code patterns](references/a11y-patterns.md).

## Accessibility focus

Focus is where apps most often break for assistive technology users. After a
sheet, alert or popover goes away, VoiceOver focus must land back on the
control that opened it.

Scope: this section is about the assistive-technology cursor. `UIFocusGuide`,
`focusSection()`, scene-focused values, and keyboard or directional focus in
general are implementation topics for the `hig-inputs` skill. Even when
triaging a broad focus bug, report accessibility traversal as its own finding:
the order and grouping of elements decide the path a VoiceOver swipe follows,
the sequence a switch scan visits, where Voice Control places its overlays and
whether Full Keyboard Access can get somewhere. The keyboard wiring goes to `hig-inputs`; the traversal impact
finding stays here.

### `@AccessibilityFocusState` (iOS 15+)

A property wrapper you can read to learn where accessibility focus is and
write to move it. One target: bind a `Bool`. Several: bind an optional enum
that conforms to `Hashable`.

```swift
struct ReceiptRow: View {
    @AccessibilityFocusState private var detailButtonFocused: Bool
    @State private var showsDetail = false

    var body: some View {
        Button("Receipt details") { showsDetail = true }
            .accessibilityFocused($detailButtonFocused)
            .sheet(isPresented: $showsDetail) {
                ReceiptDetail()
                    .onDisappear {
                        Task { @MainActor in
                            try? await Task.sleep(for: .seconds(0.1))   // about 100 ms
                            detailButtonFocused = true
                        }
                    }
            }
    }
}
```

The short pause lets the dismissal animation finish before focus moves;
moving it mid-transition tends to be ignored. `Task.sleep(for:)` needs iOS 16;
on iOS 15 use `Task.sleep(nanoseconds: 100_000_000)`.

### Several targets

```swift
enum SignUpField: Hashable { case handle, password, create }

struct SignUpForm: View {
    @State private var handle = ""
    @AccessibilityFocusState private var a11yFocus: SignUpField?

    var body: some View {
        Form {
            TextField("Handle", text: $handle)
                .accessibilityFocused($a11yFocus, equals: .handle)
            // ...same for the password field and the Create account button
        }
    }

    private func submit() { if handle.isEmpty { a11yFocus = .handle } }
}
```

When validation fails, assign the case of the offending field and VoiceOver
jumps straight to it.

### Custom modals

A hand-built overlay needs two things: the `.isModal` trait so VoiceOver
cannot wander outside it, and an escape action so the standard dismiss gesture
works.

```swift
CouponPopover(onClose: close)
    .accessibilityAddTraits(.isModal)
    .accessibilityAction(.escape) { close() }
```

Closing is part of the modal's contract: test that the VoiceOver escape
gesture (two-finger scrub) or the keyboard Escape key dismisses it. Afterwards
focus belongs on the trigger or on the next sensible element.

### UIKit notifications

```swift
UIAccessibility.post(notification: .announcement, argument: "Draft saved")
UIAccessibility.post(notification: .layoutChanged, argument: errorBanner)
UIAccessibility.post(notification: .screenChanged, argument: summaryHeader)
```

- `.announcement`: speak a status change (an export finished, a row was
  removed).
- `.layoutChanged`: part of the screen changed; focus moves to the argument.
- `.screenChanged`: a whole new screen; focus moves to the argument.

## Dynamic Type

Text scales when you use system text styles. Sizes that should grow with the
text (icons, spacing, control heights, custom hit regions) use
`@ScaledMetric(relativeTo:)`. Examples, including minimum tap targets:
[code patterns](references/a11y-patterns.md).

## Custom rotors

A rotor lets a VoiceOver user hop straight between items of one kind, such as
unread threads or headings. Add them to screens dense with content; see
[code patterns](references/a11y-patterns.md).

## System preferences

| Environment value | Type | Setting |
|---|---|---|
| `\.accessibilityReduceMotion` | `Bool` | Reduce Motion |
| `\.accessibilityReduceTransparency` | `Bool` | Reduce Transparency |
| `\.colorSchemeContrast` | `.standard` / `.increased` | Increase Contrast |
| `\.legibilityWeight` | `.regular` / `.bold` | Bold Text |

### Reduce Motion

With Reduce Motion on, replace movement with a crossfade or with no animation.

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

    var body: some View {
        VStack {
            Button("Filters") {
                withAnimation(reduceMotion ? nil : .spring()) { isExpanded.toggle() }
            }
            if isExpanded {
                PanelView()
                    .transition(reduceMotion ? .opacity : .move(edge: .bottom))
            }
        }
    }
}
```

Go through every moving transition, not just the obvious one: row removal,
quantity changes, presenting a sheet or checkout, dismissing a modal. Slides,
bounces, parallax, springs and big spatial moves become fades, an immediate
jump to the end state, or no animation at all.

### Transparency, contrast, bold text

```swift
@Environment(\.accessibilityReduceTransparency) private var reduceTransparency
@Environment(\.colorSchemeContrast) private var contrast
@Environment(\.legibilityWeight) private var legibilityWeight

private let base = Color(uiColor: .systemBackground)

var body: some View {
    Text(caption)
        .foregroundStyle(contrast == .increased ? .primary : .secondary)
        .fontWeight(legibilityWeight == .bold ? .bold : .regular)
        .background(reduceTransparency ? base : base.opacity(0.8))
}
```

## Decorative versus meaningful images

```swift
Image(decorative: "confetti")                     // never announced
Image("wave-divider").accessibilityHidden(true)   // hide an existing image
Label("Downloads", systemImage: "arrow.down")     // icon folded into the text
Button { openFilters() } label: {
    Image(systemName: "slider.horizontal.3")
}
.accessibilityLabel("Filters")                    // icon-only button needs a name
```

An image counts as decorative only when nearby accessible text already says
everything it says. If it shows a product variant, a state, a chart point,
something a user posted, or any other distinguishing detail, describe it
instead of hiding it.

## Voice Control

Spoken commands find their target through its accessibility label: the label
is the name a person says to tap the element. An element with no label, or a label nobody can say, cannot be
targeted by voice.

- Every interactive element needs a label a person can say aloud. No labels
  made only of emoji or symbols.
- Labels must differ among the elements on screen at the same time;
  duplicates push users onto overlay numbers.
- Add `accessibilityInputLabels` before feature freeze, not as late polish,
  wherever a label is long, clumsy, localized, full of acronyms, or usually
  shortened when spoken. Both Full Keyboard Access and Voice Control consume
  them. List the entries most important first.
- Good candidates: media controls, settings and account links, per-row
  actions that repeat down a list, quantity steppers, localized labels that
  contain product names or acronyms.
- Turn Voice Control on and say "Show Names", then "Show Numbers". The names
  overlay proves labels can be spoken; the numbers overlay proves every
  visible target stays reachable even if its name is missing, duplicated or
  awkward. A review
  checks both overlays.

Examples and speakable-label guidance: [code patterns](references/a11y-patterns.md).

## Switch Control

Switch Control steps through accessibility elements one at a time in reading
order, so grouping and custom actions matter more here than anywhere else.

- `.accessibilityElement(children: .combine)` merges related pieces and
  removes scan stops. Each remaining stop should mean something and do
  something.
- Anything hidden from VoiceOver as decorative is skipped by Switch Control
  too.
- Multi-finger gestures, long presses and swipe-to-delete are out of reach
  for switch users. Offer those operations as `.accessibilityAction(named:)`; Switch
  Control lists them in a menu.
- In point scanning mode, a custom control whose tappable area is unusual
  must report an `accessibilityFrame` that matches that area.

## Full Keyboard Access

Available since iOS and iPadOS 13.4, Full Keyboard Access lets people run the
app from a hardware keyboard. This skill owns the review: can everything be
reached, is it clearly labelled, is focus visible, does it work without touch.
`UIFocusGuide`, tvOS focus, scene-focused values, directional movement,
`focusSection()`, `@FocusState`, `.focusable()`, custom cards that Tab jumps
over, and Tab traversal bugs in general go to `hig-inputs` first; only the accessibility finding
stays here.

Audit:

- the keyboard alone can reach and trigger every interactive element;
- the traversal order makes sense and never traps focus;
- focus indicators stay visible at every contrast and text-size setting;
- anything that only works through a gesture has a keyboard route;
- the app's own shortcuts leave system ones (Cmd+C, Cmd+V, Cmd+Tab) alone.

## Traversal order

Always judge the effect of ordering and grouping on four things at once: the
path of VoiceOver swipes, the steps of a switch scan, where Voice Control
overlays land, and what the keyboard can reach. All four go wrong together
when grouping or order disagrees with the visual layout or the task order. Usual causes:
missing labels, duplicate labels, rows exposing too many children, custom
controls hidden from accessibility, grouping that does not match what the eye
sees. The keyboard and directional routing mechanics are `hig-inputs` work;
the ordering audit and its accessibility impact are reported here.

## Assistive Access (iOS 18+)

This pared-down system mode serves people with cognitive disabilities, and
apps should behave well inside it.

```swift
struct LibraryScreen: View {
    @Environment(\.accessibilityAssistiveAccessEnabled) private var assistiveAccess

    var body: some View {
        if assistiveAccess { SimpleLibraryView() } else { FullLibraryView() }
    }
}
```

- Less on screen: fewer controls, bigger targets, flatter navigation.
- Literal, plain wording in labels and instructions.
- Only a few choices at a time.
- Try it under Settings > Accessibility > Assistive Access.

From iOS 26 an app can also declare `UISupportsAssistiveAccess` in its
Info.plist and provide a dedicated `AssistiveAccess` scene.

## UIKit

- `isAccessibilityElement = true` on custom views that carry meaning.
- `accessibilityLabel` on every interactive element without visible text.
- Change traits with `.insert()` and `.remove()` instead of assigning:
  `badgeView.accessibilityTraits.insert(.button)`,
  `badgeView.accessibilityTraits.remove(.staticText)`.
- `accessibilityViewIsModal = true` on custom overlays to keep focus inside.
- Post `.announcement` for passing status, `.layoutChanged` with a target for
  partial updates, `.screenChanged` for full transitions.

## AppKit

AppKit accessibility rests on `NSAccessibilityProtocol` plus role-specific
protocols. Standard controls already publish most of what assistive apps need,
including notifications, so prefer them and customise only where the defaults
fall short.

- A custom `NSView` adopts the right role behaviour and reports its role,
  label, value and supported actions accurately.
- Items with no view behind them use `NSAccessibilityElement`.
- Post `NSAccessibility` notifications when a change must reach assistive
  apps.

The full custom-view example (a toggle-like `NSView` returning role, label,
"On"/"Off" value and a press action, posting `.valueChanged` when its state
changes) and `NSAccessibilityElement` usage are in
[code patterns](references/a11y-patterns.md#appkit).

## Custom content

`.accessibilityCustomContent` attaches extra labelled facts to an element.
By default VoiceOver offers them only on request (More Content rotor); with
`importance: .high` they are read along with the element.

```swift
HotelCard(hotel: hotel)
    .accessibilityCustomContent("Nightly rate", Text(hotel.rateText), importance: .high)
    .accessibilityCustomContent("Guest score", Text(hotel.scoreText))
```

More UIKit, AppKit and custom-content examples:
[code patterns](references/a11y-patterns.md).

## App Store Accessibility Nutrition Labels

Questions about the labels, accessibility claims on the product page, or the
accessibility answers in App Store Connect: open the
[nutrition label reference](references/nutrition-labels.md) first. A claim is
only recommended after you:

- ask for evidence that users can finish every common task with that feature
  on the device type in question;
- lay the evidence out as a matrix of common tasks against features;
- include transcripts where audio-only content needs a text equivalent;
- say plainly that these answers must stay accurate and are not marketing.

## Testing

### By hand

| Tool | What to do | Where |
|---|---|---|
| Accessibility Inspector | Run an audit against the Simulator or a device; it reports contrast issues, absent labels and wrong traits | Xcode > Open Developer Tool |
| VoiceOver | Swipe from the first element to the last on each screen | Settings > Accessibility > VoiceOver |
| Voice Control | Say "Show Names", then "Show Numbers" | Settings > Accessibility > Voice Control |
| Full Keyboard Access | Press Tab across each screen and confirm each control receives focus | Settings > Accessibility > Keyboards > Full Keyboard Access |
| Switch Control | Watch the scan order; gesture features need custom actions | Settings > Accessibility > Switch Control |
| Dynamic Type | Step through the whole size range | Settings > Accessibility > Display & Text Size > Larger Text |

### With XCTest

UI tests can assert accessibility through `XCUIElement` attributes. The useful
ones in `XCUIElementAttributes` are `identifier`, `label`, `title`, `value`,
`placeholderValue`, `isSelected`, `isEnabled` and `hasFocus`.

```swift
@MainActor
func testFocusReturnsAfterSheet() {
    let app = XCUIApplication()
    app.launch()

    let opener = app.buttons["Seat map"]
    opener.tap()
    app.buttons["Done"].tap()
    XCTAssertTrue(opener.hasFocus)
}
```

A row test (querying `app.cells["id"]`, asserting `exists`, `isEnabled`, a
non-empty `label` and a nested `buttons[...]` query) is in
[code patterns](references/a11y-patterns.md#automated-checks-with-xctest).

`hasFocus` reports keyboard focus (Full Keyboard Access, hardware keyboard),
not the VoiceOver cursor, which XCTest cannot observe. Confirm VoiceOver focus
restoration by hand as well.

## Mistakes seen most often

- Assigning traits (or using the wrong trait API) wipes existing behaviour;
  use `.accessibilityAddTraits(.isButton)`.
- A sheet closes and VoiceOver focus is not put back on its trigger.
- Ungrouped list rows, one swipe stop per subview; combine them.
- The control type inside the label: `.accessibilityLabel("Filter button")`
  is heard as "Filter button, button".
- `Image`-only buttons without `.accessibilityLabel`.
- Movement animations that never check `accessibilityReduceMotion`.
- Fixed sizes like `.font(.system(size: 15))` instead of `.font(.body)`.
- Small targets; fix with `.frame(minWidth: 44, minHeight: 44)` and
  `.contentShape(Rectangle())`.
- Red versus green as the only error or success signal.
- A custom modal without `.accessibilityAddTraits(.isModal)`, so VoiceOver
  wanders out of it.

## Review checklist

- [ ] Every interactive element has a label.
- [ ] Custom controls get traits through `.accessibilityAddTraits`.
- [ ] Adjustable custom controls use `.accessibilityAdjustableAction`, or the
      UIKit `.adjustable` trait.
- [ ] Decorative images are hidden (`Image(decorative:)` or
      `.accessibilityHidden(true)`).
- [ ] Sheets and dialogs give focus back to their trigger.
- [ ] Each list row is a single combined element
      (`.accessibilityElement(children: .combine)`).
- [ ] Custom overlays carry `.isModal` and an escape action.
- [ ] Every tap target is at least 44x44 pt.
- [ ] Dynamic Type works (`@ScaledMetric`, system fonts, adaptive layout).
- [ ] Reduce Motion removes movement animations.
- [ ] Each animated flow (sheet, modal, checkout, row changes) has a
      reduced-motion version.
- [ ] Reduce Transparency gets solid backgrounds.
- [ ] Increase Contrast gets stronger foreground colors.
- [ ] Nothing is communicated by color alone.
- [ ] Context menus and swipe-revealed actions are also offered as custom
      actions.
- [ ] Icon-only buttons are labelled.
- [ ] Section headers have the header trait.
- [ ] Any custom accessibility type or notification payload that crosses an
      isolation boundary conforms to `Sendable`.
- [ ] Voice Control labels are speakable and unique (no emoji-only labels, no
      duplicates on screen).
- [ ] Voice Control was exercised with the "Show Numbers" overlay as well as
      "Show Names".
- [ ] Long or awkward labels have `accessibilityInputLabels`.
- [ ] Long-press and swipe-to-delete can also be done through custom actions,
      for switch users.
- [ ] With Full Keyboard Access every control can be reached and triggered,
      and focus never gets stuck.
- [ ] Ordering and grouping were reviewed for their effect on all four
      traversal paths (VoiceOver, switch scanning, Voice Control overlays,
      keyboard).
- [ ] The app leaves system keyboard shortcuts alone.
