iOS Accessibility Skill
Core Rules
EVERY interactive element must have a meaningful accessibilityLabel.
Never use generic labels like "button", "image", or "icon". Describe what it does: "Delete message", "Add to favorites", "Share photo".
Use native SwiftUI controls (Button, Toggle, Link, Picker, Slider, Stepper) whenever possible. They carry correct accessibility traits automatically.
Custom views with onTapGesture MUST add .accessibilityAddTraits(.isButton).
Better yet, wrap them in a Button so VoiceOver announces them as interactive.
Decorative images must be hidden from VoiceOver.
Use Image(decorative:) or .accessibilityHidden(true). Never let VoiceOver read "image" for decorative content.
Support Dynamic Type at ALL sizes including accessibility sizes (AX1-AX5). Use system text styles (.body, .title, .headline) and @ScaledMetric for custom dimensions.
Minimum touch target: 44x44 points. Use .frame(minWidth: 44, minHeight: 44) and .contentShape(.rect) to expand small icons.
Never re-prompt after .userCancel. When the user dismisses a biometric prompt or permission dialog, respect their decision. Do not show the prompt again immediately.
Test with VoiceOver on a real device, not just the Simulator. The Simulator does not fully replicate VoiceOver behavior, gestures, or focus management.
Run performAccessibilityAudit() in UI tests. Automated audits catch contrast issues, missing labels, small hit regions, and clipped text.
Add NSFaceIDUsageDescription to Info.plist when using Face ID. Without it the app crashes on first biometric attempt.
Color must not be the sole indicator. Always pair color with text, icons, or shapes. Check accessibilityDifferentiateWithoutColor.
Respect Reduce Motion. Check accessibilityReduceMotion and provide crossfade alternatives to spring/slide animations.
Logical reading order matters. VoiceOver reads left-to-right, top-to-bottom. Use .accessibilitySortPriority() to fix order when layout does not match logical flow.
Move focus to important changes. When an error appears or content updates, post AccessibilityNotification.LayoutChanged or use @AccessibilityFocusState.
Group related elements with .accessibilityElement(children: .combine) to reduce swipe count and create meaningful compound descriptions.
Quick Checklist
Before shipping any screen, verify:
SwiftUI Accessibility Modifier Quick Reference
Labels, Hints, Values
// Static label
.accessibilityLabel("Delete message")
// Closure label (iOS 18+)
.accessibilityLabel { label in
Text("Unread") + label
}
// Hint — describes the result of activating
.accessibilityHint("Double tap to delete this message")
// Value — current state of a control
.accessibilityValue("50 percent")
// Input labels — Voice Control alternate names
.accessibilityInputLabels(["Delete", "Remove", "Trash"])
Traits
.accessibilityAddTraits(.isButton)
.accessibilityAddTraits(.isHeader)
.accessibilityAddTraits(.isLink)
.accessibilityAddTraits(.isSelected)
.accessibilityAddTraits(.isImage)
.accessibilityRemoveTraits(.isImage)
Grouping and Hiding
// Combine children into one element
.accessibilityElement(children: .combine)
// Hide from VoiceOver
.accessibilityHidden(true)
// Decorative image (hidden automatically)
Image(decorative: "background-pattern")
// Replace entire subtree with custom element
.accessibilityRepresentation {
Toggle("Wi-Fi", isOn: $isEnabled)
}
Actions
// Named action (appears in custom actions rotor)
.accessibilityAction(named: "Mark as read") {
markAsRead()
}
// Adjustable (swipe up/down)
.accessibilityAdjustableAction { direction in
switch direction {
case .increment: value += 1
case .decrement: value -= 1
@unknown default: break
}
}
Focus Management
@AccessibilityFocusState private var isFocused: Bool
TextField("Name", text: $name)
.accessibilityFocused($isFocused)
// Move focus programmatically
Button("Show Error") {
errorMessage = "Invalid input"
isFocused = true
}
Dynamic Type Support
// System text style (scales automatically)
Text("Hello").font(.body)
// Custom font that scales with Dynamic Type
Text("Hello").font(.custom("Avenir", size: 17, relativeTo: .body))
// Scaled dimension
@ScaledMetric(relativeTo: .body) private var iconSize: CGFloat = 24
// Respond to accessibility sizes
@Environment(\.dynamicTypeSize) private var typeSize
if typeSize.isAccessibilitySize {
// Switch to vertical layout
}
Common Patterns
Card with Combined Accessibility
VStack(alignment: .leading) {
Text(item.title).font(.headline)
Text(item.subtitle).font(.subheadline)
Text(item.date).font(.caption)
}
.accessibilityElement(children: .combine)
.accessibilityAddTraits(.isButton)
.accessibilityHint("Double tap to view details")
Custom Toggle
// BAD: VoiceOver does not know this is interactive
HStack {
Text("Dark Mode")
Image(systemName: isDark ? "moon.fill" : "moon")
}
.onTapGesture { isDark.toggle() }
// GOOD: Use a real Toggle
Toggle("Dark Mode", isOn: $isDark)
// GOOD: If custom UI is needed, add representation
HStack {
Text("Dark Mode")
Image(systemName: isDark ? "moon.fill" : "moon")
}
.onTapGesture { isDark.toggle() }
.accessibilityRepresentation {
Toggle("Dark Mode", isOn: $isDark)
}
Swipeable List Row with Actions
ForEach(messages) { message in
MessageRow(message: message)
.accessibilityAction(named: "Delete") {
delete(message)
}
.accessibilityAction(named: "Archive") {
archive(message)
}
.accessibilityAction(named: "Mark as read") {
markAsRead(message)
}
}
Error Announcement
@AccessibilityFocusState private var isErrorFocused: Bool
VStack {
TextField("Email", text: $email)
if let error = validationError {
Text(error)
.foregroundStyle(.red)
.accessibilityFocused($isErrorFocused)
}
Button("Submit") {
if !validate() {
isErrorFocused = true
}
}
}
Responsive Layout for Large Type
struct AdaptiveRow: View {
@Environment(\.dynamicTypeSize) private var typeSize
var body: some View {
let layout = typeSize.isAccessibilitySize
? AnyLayout(VStackLayout(alignment: .leading, spacing: 8))
: AnyLayout(HStackLayout(spacing: 12))
layout {
Image(systemName: "star.fill")
.accessibilityHidden(true)
Text("Favorites")
Spacer()
Text("12 items")
.foregroundStyle(.secondary)
}
}
}
References
For detailed API coverage, see:
- VoiceOver — labels, hints, values, traits, actions, rotors, focus
- Dynamic Type — text styles, @ScaledMetric, layout adaptation, color and contrast, motion
- Auditing — Xcode Inspector, XCTest audits, environment values, best practices
1---2name: ios-accessibility3description: iOS accessibility (a11y) expert skill covering VoiceOver support (labels, hints, values, traits, custom actions, rotors, focus management), Dynamic Type (text styles, @ScaledMetric, layout adaptation for large sizes), color and contrast (WCAG ratios, Differentiate Without Color, Smart Invert), motion and reduce motion, accessibility auditing (Xcode Inspector, XCTest performAccessibilityAudit), and SwiftUI accessibility modifiers. Use this skill whenever the user implements accessibility features, needs VoiceOver support, handles Dynamic Type, adds accessibility labels, or audits an app for a11y compliance. Triggers on: accessibility, a11y, VoiceOver, Dynamic Type, accessibilityLabel, accessibilityHint, accessibilityValue, accessibilityTraits, accessibilityAction, accessibilityIdentifier, screen reader, assistive technology, reduce motion, high contrast, accessible, WCAG, touch target, font scaling, @ScaledMetric, accessibilityElement, AX audit, inclusive design, or any iOS accessibility question.4---56# iOS Accessibility Skill78## Core Rules9101. **EVERY interactive element must have a meaningful `accessibilityLabel`.**11 Never use generic labels like "button", "image", or "icon". Describe what it does: "Delete message", "Add to favorites", "Share photo".12132. **Use native SwiftUI controls** (`Button`, `Toggle`, `Link`, `Picker`, `Slider`, `Stepper`) whenever possible. They carry correct accessibility traits automatically.14153. **Custom views with `onTapGesture` MUST add `.accessibilityAddTraits(.isButton)`.**16 Better yet, wrap them in a `Button` so VoiceOver announces them as interactive.17184. **Decorative images must be hidden from VoiceOver.**19 Use `Image(decorative:)` or `.accessibilityHidden(true)`. Never let VoiceOver read "image" for decorative content.20215. **Support Dynamic Type at ALL sizes** including accessibility sizes (AX1-AX5). Use system text styles (`.body`, `.title`, `.headline`) and `@ScaledMetric` for custom dimensions.22236. **Minimum touch target: 44x44 points.** Use `.frame(minWidth: 44, minHeight: 44)` and `.contentShape(.rect)` to expand small icons.24257. **Never re-prompt after `.userCancel`.** When the user dismisses a biometric prompt or permission dialog, respect their decision. Do not show the prompt again immediately.26278. **Test with VoiceOver on a real device**, not just the Simulator. The Simulator does not fully replicate VoiceOver behavior, gestures, or focus management.28299. **Run `performAccessibilityAudit()` in UI tests.** Automated audits catch contrast issues, missing labels, small hit regions, and clipped text.303110. **Add `NSFaceIDUsageDescription`** to Info.plist when using Face ID. Without it the app crashes on first biometric attempt.323311. **Color must not be the sole indicator.** Always pair color with text, icons, or shapes. Check `accessibilityDifferentiateWithoutColor`.343512. **Respect Reduce Motion.** Check `accessibilityReduceMotion` and provide crossfade alternatives to spring/slide animations.363713. **Logical reading order matters.** VoiceOver reads left-to-right, top-to-bottom. Use `.accessibilitySortPriority()` to fix order when layout does not match logical flow.383914. **Move focus to important changes.** When an error appears or content updates, post `AccessibilityNotification.LayoutChanged` or use `@AccessibilityFocusState`.404115. **Group related elements** with `.accessibilityElement(children: .combine)` to reduce swipe count and create meaningful compound descriptions.4243---4445## Quick Checklist4647Before shipping any screen, verify:4849- [ ] Every `Button`, `Link`, and tappable element has an `accessibilityLabel`50- [ ] Decorative images use `Image(decorative:)` or `.accessibilityHidden(true)`51- [ ] Section headers are marked with `.accessibilityAddTraits(.isHeader)`52- [ ] Dynamic Type renders correctly at all sizes including `.accessibilityExtraExtraExtraLarge`53- [ ] Touch targets are at least 44x44 points54- [ ] Color is not the only way to convey information (errors, status, selection)55- [ ] Animations respect `accessibilityReduceMotion`56- [ ] Complex interactions have custom actions as swipe alternatives57- [ ] Reading order is logical (matches visual and semantic order)58- [ ] Focus moves to errors, alerts, or newly inserted content59- [ ] Modal views use `.accessibilityAddTraits(.isModal)` to trap focus60- [ ] Adjustable controls (sliders, steppers) work with swipe up/down61- [ ] `accessibilityValue` reflects current state for toggles and sliders62- [ ] `accessibilityHint` is set for non-obvious actions (starts with verb phrase)63- [ ] UI tests include `performAccessibilityAudit()`6465---6667## SwiftUI Accessibility Modifier Quick Reference6869### Labels, Hints, Values7071```swift72// Static label73.accessibilityLabel("Delete message")7475// Closure label (iOS 18+)76.accessibilityLabel { label in77 Text("Unread") + label78}7980// Hint — describes the result of activating81.accessibilityHint("Double tap to delete this message")8283// Value — current state of a control84.accessibilityValue("50 percent")8586// Input labels — Voice Control alternate names87.accessibilityInputLabels(["Delete", "Remove", "Trash"])88```8990### Traits9192```swift93.accessibilityAddTraits(.isButton)94.accessibilityAddTraits(.isHeader)95.accessibilityAddTraits(.isLink)96.accessibilityAddTraits(.isSelected)97.accessibilityAddTraits(.isImage)98.accessibilityRemoveTraits(.isImage)99```100101### Grouping and Hiding102103```swift104// Combine children into one element105.accessibilityElement(children: .combine)106107// Hide from VoiceOver108.accessibilityHidden(true)109110// Decorative image (hidden automatically)111Image(decorative: "background-pattern")112113// Replace entire subtree with custom element114.accessibilityRepresentation {115 Toggle("Wi-Fi", isOn: $isEnabled)116}117```118119### Actions120121```swift122// Named action (appears in custom actions rotor)123.accessibilityAction(named: "Mark as read") {124 markAsRead()125}126127// Adjustable (swipe up/down)128.accessibilityAdjustableAction { direction in129 switch direction {130 case .increment: value += 1131 case .decrement: value -= 1132 @unknown default: break133 }134}135```136137### Focus Management138139```swift140@AccessibilityFocusState private var isFocused: Bool141142TextField("Name", text: $name)143 .accessibilityFocused($isFocused)144145// Move focus programmatically146Button("Show Error") {147 errorMessage = "Invalid input"148 isFocused = true149}150```151152### Dynamic Type Support153154```swift155// System text style (scales automatically)156Text("Hello").font(.body)157158// Custom font that scales with Dynamic Type159Text("Hello").font(.custom("Avenir", size: 17, relativeTo: .body))160161// Scaled dimension162@ScaledMetric(relativeTo: .body) private var iconSize: CGFloat = 24163164// Respond to accessibility sizes165@Environment(\.dynamicTypeSize) private var typeSize166167if typeSize.isAccessibilitySize {168 // Switch to vertical layout169}170```171172---173174## Common Patterns175176### Card with Combined Accessibility177178```swift179VStack(alignment: .leading) {180 Text(item.title).font(.headline)181 Text(item.subtitle).font(.subheadline)182 Text(item.date).font(.caption)183}184.accessibilityElement(children: .combine)185.accessibilityAddTraits(.isButton)186.accessibilityHint("Double tap to view details")187```188189### Custom Toggle190191```swift192// BAD: VoiceOver does not know this is interactive193HStack {194 Text("Dark Mode")195 Image(systemName: isDark ? "moon.fill" : "moon")196}197.onTapGesture { isDark.toggle() }198199// GOOD: Use a real Toggle200Toggle("Dark Mode", isOn: $isDark)201202// GOOD: If custom UI is needed, add representation203HStack {204 Text("Dark Mode")205 Image(systemName: isDark ? "moon.fill" : "moon")206}207.onTapGesture { isDark.toggle() }208.accessibilityRepresentation {209 Toggle("Dark Mode", isOn: $isDark)210}211```212213### Swipeable List Row with Actions214215```swift216ForEach(messages) { message in217 MessageRow(message: message)218 .accessibilityAction(named: "Delete") {219 delete(message)220 }221 .accessibilityAction(named: "Archive") {222 archive(message)223 }224 .accessibilityAction(named: "Mark as read") {225 markAsRead(message)226 }227}228```229230### Error Announcement231232```swift233@AccessibilityFocusState private var isErrorFocused: Bool234235VStack {236 TextField("Email", text: $email)237238 if let error = validationError {239 Text(error)240 .foregroundStyle(.red)241 .accessibilityFocused($isErrorFocused)242 }243244 Button("Submit") {245 if !validate() {246 isErrorFocused = true247 }248 }249}250```251252### Responsive Layout for Large Type253254```swift255struct AdaptiveRow: View {256 @Environment(\.dynamicTypeSize) private var typeSize257258 var body: some View {259 let layout = typeSize.isAccessibilitySize260 ? AnyLayout(VStackLayout(alignment: .leading, spacing: 8))261 : AnyLayout(HStackLayout(spacing: 12))262263 layout {264 Image(systemName: "star.fill")265 .accessibilityHidden(true)266 Text("Favorites")267 Spacer()268 Text("12 items")269 .foregroundStyle(.secondary)270 }271 }272}273```274275---276277## References278279For detailed API coverage, see:280281- [VoiceOver](references/voiceover.md) — labels, hints, values, traits, actions, rotors, focus282- [Dynamic Type](references/dynamic-type.md) — text styles, @ScaledMetric, layout adaptation, color and contrast, motion283- [Auditing](references/auditing.md) — Xcode Inspector, XCTest audits, environment values, best practices