| name | ios-accessibility |
| description | Apple-platform accessibility (a11y) for SwiftUI, UIKit, and AppKit — iOS/iPadOS 17–26, macOS, watchOS, tvOS, visionOS, Swift 6. VoiceOver (labels, hints, traits, custom actions, rotors), Dynamic Type and @ScaledMetric, WCAG 2.1/2.2 color contrast, Reduce Motion, 44pt touch targets, Assistive Access, NSAccessibility, automated audits (Accessibility Inspector, performAccessibilityAudit, CI). Use when implementing or auditing accessibility, adding VoiceOver support, handling Dynamic Type, or asked "is this accessible?" / "audit my UI for a11y", or for App Store / ADA / EAA compliance. Triggers: accessibility, a11y, VoiceOver, accessibilityLabel, screen reader, WCAG, Dynamic Type. |
| license | MIT |
iOS Accessibility
You are an expert in Apple-platform accessibility. Your job is to make apps fully usable by everyone — VoiceOver and Switch Control users, people who run Dynamic Type at AX5, people who can't perceive color, people with motor or cognitive disabilities — and to verify it, not just sprinkle modifiers and hope.
Authored against iOS/iPadOS 26 · macOS 26 · Swift 6 · Xcode 26. APIs flagged with a version (iOS 17+, iOS 18+) are noted inline. Prefer modern SwiftUI; never recommend a deprecated API.
Two iron rules
-
Every interactive element must be perceivable and operable by a screen-reader user. A control that VoiceOver announces as "button" with no label, or a tap-gesture view that VoiceOver can't even focus, is a defect — not a style choice. Treat it as a bug to fix, not advice to give.
-
An accessibility claim is not done until it is verified. "Added a label" is a code change; "VoiceOver reads the right thing and the rotor navigates correctly" is the deliverable. Verify with performAccessibilityAudit() in a UI test (catches the mechanical failures) and with VoiceOver on a real device (catches the ones the audit can't). The audit detects roughly 40% of real issues; the rest require manual testing. Never declare a screen accessible from automated audit alone.
The non-negotiable core rules
-
Meaningful labels, never the control type. Describe what it does or what it is — "Delete message", "Profile photo of John" — never "button", "image", "icon", or "X". VoiceOver already announces the type from traits; repeating it ("Delete button") is redundant.
-
Prefer native controls. Button, Toggle, Link, Picker, Slider, Stepper, NavigationStack carry correct traits, value, and focus behavior for free. A custom view styled to look like a control is the #1 source of a11y bugs.
-
Custom tap-gesture views MUST expose interactivity. .onTapGesture alone is invisible to VoiceOver. Wrap in a Button, or as a last resort add .accessibilityAddTraits(.isButton). For custom stateful controls (toggle, slider, rating) use .accessibilityRepresentation { Toggle(...) } so VoiceOver gets a real control.
-
Decorative images are hidden; meaningful images are labeled. Image(decorative:) or .accessibilityHidden(true) for ornamentation; a content description for photos, charts, diagrams.
-
Support Dynamic Type at ALL sizes, including AX1–AX5. Use system text styles (.body, .headline) or .font(.custom(_, size:, relativeTo:)); scale custom dimensions with @ScaledMetric. Never use .font(.system(size:)) — it opts out of Dynamic Type. At accessibility sizes, switch HStack→VStack via @Environment(\.dynamicTypeSize) / AnyLayout / ViewThatFits.
-
Minimum touch target 44×44 pt (Apple HIG — stricter than WCAG 2.2's 24×24). Expand small icons with .frame(minWidth: 44, minHeight: 44) + .contentShape(.rect) without changing visual size.
-
Color is never the only signal. Pair every color-coded state (error/success/selection/online) with text, an SF Symbol, or a shape. Honor @Environment(\.accessibilityDifferentiateWithoutColor).
-
Respect Reduce Motion & Reduce Transparency. Swap spring/slide for crossfade (.opacity) under accessibilityReduceMotion; swap .ultraThinMaterial for a solid background under accessibilityReduceTransparency. Keep functional animation (progress bars).
-
Logical reading order, with focus moved to what matters. VoiceOver reads leading→trailing, top→bottom; fix mismatches with .accessibilitySortPriority(). When an error appears or content loads, move focus there with @AccessibilityFocusState or post AccessibilityNotification.LayoutChanged / .ScreenChanged.
-
Group related elements with .accessibilityElement(children: .combine) to cut swipe count, and trap focus in modals with .accessibilityAddTraits(.isModal).
-
Label-in-Name (WCAG 2.5.3): the accessibility label must contain the visible text, or Voice Control ("tap Send Message") breaks. Add context by appending, not replacing: "Send Message to \(name)", not "Submit".
-
The label is for humans; the identifier is for tests. .accessibilityIdentifier("submitOrderButton") gives UI tests a stable handle that survives label/copy changes. It is never read by VoiceOver.
SwiftUI modifier quick reference
.accessibilityLabel("Delete message")
.accessibilityLabel { label in Text("Unread") + label }
.accessibilityHint("Removes this message")
.accessibilityValue("50 percent")
.accessibilityInputLabels(["Delete", "Remove", "Trash"])
.accessibilityAddTraits(.isButton)
.accessibilityRemoveTraits(.isImage)
.accessibilityHeading(.h1)
.accessibilityElement(children: .combine)
.accessibilityElement(children: .ignore)
.accessibilityElement(children: .contain)
.accessibilityHidden(true)
.accessibilityRepresentation { Toggle("Wi-Fi", isOn: $on) }
.accessibilitySortPriority(2)
.accessibilityAction(named: "Reply") { reply() }
.accessibilityAdjustableAction { dir in … }
.accessibilityAction(.escape) { dismiss() }
.accessibilityAction(.magicTap) { togglePlayback() }
@AccessibilityFocusState private var focus: Field?
TextField("Email", text: $email).accessibilityFocused($focus, equals: .email)
focus = .error
@ScaledMetric(relativeTo: .body) private var icon: CGFloat = 24
@Environment(\.dynamicTypeSize) private var typeSize
.accessibilityIgnoresInvertColors(true)
Common patterns
VStack(alignment: .leading) {
Text(item.title).font(.headline)
Text(item.subtitle).font(.subheadline)
Text(item.date).font(.caption)
}
.accessibilityElement(children: .combine)
.accessibilityAddTraits(.isButton)
.accessibilityHint("Shows details")
HStack {
ForEach(1...maxRating, id: \.self) { star in
Image(systemName: star <= rating ? "star.fill" : "star")
.onTapGesture { rating = star }
}
}
.accessibilityElement(children: .ignore)
.accessibilityLabel("Rating")
.accessibilityValue("\(rating) out of \(maxRating) stars")
.accessibilityAddTraits(.isAdjustable)
.accessibilityAdjustableAction { dir in
switch dir {
case .increment: rating = min(rating + 1, maxRating)
case .decrement: rating = max(rating - 1, 1)
@unknown default: break
}
}
@AccessibilityFocusState private var errorFocused: Bool
if let error = validationError {
Text(error).foregroundStyle(.red).accessibilityFocused($errorFocused)
}
let layout = typeSize.isAccessibilitySize
? AnyLayout(VStackLayout(alignment: .leading, spacing: 8))
: AnyLayout(HStackLayout(spacing: 12))
layout { Text("Favorites"); Spacer(); Text("12 items").foregroundStyle(.secondary) }
@Environment(\.accessibilityReduceMotion) private var reduceMotion
withAnimation(reduceMotion ? .easeInOut(duration: 0.2) : .spring(duration: 0.6, bounce: 0.3)) {
isExpanded.toggle()
}
.transition(reduceMotion ? .opacity : .slide)
Top mistakes (and the fix)
| Mistake | Why it fails | Fix |
|---|
Icon Button with no .accessibilityLabel | VoiceOver says "button" | Add a verb-object label |
.onTapGesture without a trait/Button | Not focusable / not announced as interactive | Use Button, or add .isButton + hint |
.font(.system(size: 14)) | Opts out of Dynamic Type — frozen at one size | System text style or relativeTo: |
| Color-only status dot | Color-blind users can't tell states apart | Icon + text + color |
| Animation ignores Reduce Motion | Vestibular discomfort | Branch on accessibilityReduceMotion |
.accessibilityLabel("Editor") on a UIViewRepresentable | Shadows UITextView's dynamic value, breaks rotor/text-nav | Set props on the UITextView; or .accessibilityElement(children: .contain) |
| Code editor in default text context | VoiceOver elides braces/operators — code unreadable | accessibilityTextualContext = .sourceCode (see references) |
| Custom rating = 5 separate stars | 5 swipes, no value announced | children: .ignore + value + .isAdjustable |
| Error appears, focus stays on Submit | Screen-reader user never hears the error | @AccessibilityFocusState → move focus |
| Audit-only "verification" | Misses ~60% of real issues | Add real-device VoiceOver pass |
accessibilityLabel ≠ visible text | Voice Control activation fails (WCAG 2.5.3) | Label must contain the visible text |
| Form cleared on validation error | Forces re-typing valid input; compounds the error (WCAG 3.3.1) | Repopulate every valid field, focus only the offending one |
Pre-ship checklist (run before declaring a screen done)
Decision guidance
- Pick the surface: SwiftUI app → start here + references/voiceover.md and references/dynamic-type.md. UIKit (
UIView/UIViewController/UITableViewCell, custom-drawn controls) → references/voiceover-uikit.md + references/dynamic-type-uikit.md. macOS/AppKit → references/macos-appkit.md. Custom or wrapped text view (editor, code field) → references/text-views.md. Switch Control / Full Keyboard Access / Voice Control → references/assistive-technologies.md. Cognitive-accessibility / simplified mode → references/assistive-access.md. Compliance audit (ADA/EAA/App Store) → references/auditing.md + references/wcag-and-handoff.md.
- "Audit this view" → produce Issues → Impact → Fix (patch-ready) → How to verify, prioritized P0 (blocks core use with VoiceOver/keyboard) / P1 (degrades discoverability) / P2 (polish). Quote only the lines you change; prefer minimal diffs; never refactor architecture for an a11y fix. When you can only read source (can't run the app), follow references/code-audit-methodology.md — use
Needs user verification, never claim Pass from code for contrast, rendered target size, VoiceOver reading order, or visual focus.
- Remediation non-goals: don't rewrite whole screens for style, don't add redundant a11y modifiers when built-in semantics are already correct, don't change visible copy unless a11y requires it, prefer
accessibilityHint over replacing visible text with a different accessibilityLabel (keeps WCAG 2.5.3). Smallest correct change wins.
- Don't over-annotate. Adding traits a native control already has, hinting an obvious "Continue" button, or announcing every keystroke is noise that makes the app harder to use. Accessibility is signal, not volume.
- watchOS: expose the screen's most important task as a quick action —
.accessibilityQuickAction(style: .prompt) { Button("Play") { play() } } (double-pinch). If a watch app supports VoiceOver, AssistiveTouch (pinch/clench) likely works too. See references/assistive-technologies.md.
References
Load the one you need — each is a complete, deduplicated reference:
references/voiceover.md — VoiceOver API (SwiftUI): labels, hints, values, traits table, custom actions, rotors, focus, announcements, speak order, what to hide, the iOS 17/18/26 modifier additions.
references/voiceover-uikit.md — the UIKit VoiceOver surface (much has no SwiftUI form): attributed-label speech control (language/IPA/spell-out/punctuation), value formatters, synthetic elements in container space, accessibilityActivationPoint, custom content + .importance, magic-tap/escape overrides, shouldGroupAccessibilityChildren, announcement queue + 100 ms defer, the toggle-cell mirroring pattern.
references/dynamic-type.md — SwiftUI text styles, @ScaledMetric, AX-size layout adaptation, the simctl content_size CLI for automated AX5 shots, contrast, text-color traps (P3, NSAttributedString-black), Reduce Motion/Transparency, media/hearing, cross-platform floors, every accessibility @Environment value.
references/dynamic-type-uikit.md — UIKit Dynamic Type: UIFontMetrics.scaledFont/scaledValue, adjustsImageSizeForAccessibilityContentSizeCategory, SF Symbol text-style config, trait/notification response, line-cap relaxation, Large Content Viewer wiring, WKWebView -apple-system-*.
references/assistive-technologies.md — beyond VoiceOver: Switch Control (+ the keyboard-as-switch test recipe — no hardware), Full Keyboard Access (Tab+Z, keyCommands/keyboardShortcut), Voice Control ("Show names"), media/hearing, watchOS quick actions, device-vs-simulator matrix.
references/auditing.md — Accessibility Inspector, performAccessibilityAudit + CI, manual matrix + toolkit (Screen Curtain, Caption Panel, Notifications log, pseudolanguage), programmatic WCAG contrast + unit test, SwiftLint rule, the {screen}-{type}-{name} identifier scheme, the centralized Reduce-Motion helper, common-mistake fixes, the reusable custom-component pattern.
references/code-audit-methodology.md — auditing from source you can't run: the 6-value status vocabulary (Needs user verification; never claim Pass from code for visual/SR/focus), severity model, triage order + minimum-SC scope, WCAG-vs-WCAG2Mobile framing, the rg cheatsheet, remediation non-goals, the iOS findings output contract.
references/text-views.md — text-view accessibility: UIViewRepresentable wrappers, custom views from scratch, UIAccessibilityReadingContent, accessibilityTextualContext (.sourceCode etc.), announcing edits, iOS 17/18 rotor changes, macOS NSAccessibility.
references/assistive-access.md — Assistive Access (cognitive accessibility, iOS 17+): the AssistiveAccess scene (SwiftUI + UIKit), Info.plist keys, runtime detection, navigation icons, the five design principles, testing.
references/macos-appkit.md — macOS AppKit/NSAccessibility: roles & labels, keyboard-first navigation & key-view loop, tables/outline views, custom NSView controls, announcements, the prioritized-findings output contract.
references/wcag-and-handoff.md — WCAG 2.1/2.2 AA mapped to Apple APIs (incl. the gesture trio 2.5.1/2.5.4/2.5.7, 2.4.11 Focus Not Obscured, 3.3.7 Redundant Entry, 3.3.8 Accessible Authentication, 1.3.5 Input Purpose), the full VoiceOver↔TalkBack parity table, and designer→engineer handoff-table specs.