skills/ios-accessibility/SKILL.md
Implements, reviews, or improves accessibility in iOS/macOS apps with SwiftUI, UIKit, and AppKit. Use when adding VoiceOver, Voice Control, Switch Control, or Full Keyboard Access support; when working with accessibility labels, hints, values, traits, accessibilityInputLabels, NSAccessibility, grouping, reading order, accessibility focus restoration with @AccessibilityFocusState, Dynamic Type, @ScaledMetric, custom rotors, accessibility actions, XCTest accessibility checks, App Store Accessibility Nutrition Labels, App Store Connect accessibility answers, a11y compliance audits, or system accessibility preferences.
npx skillsauth add dpearson2699/swift-ios-skills ios-accessibilityInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Every user-facing view must be usable with VoiceOver, Switch Control, Voice Control, Full Keyboard Access, and other assistive technologies. This skill covers SwiftUI, UIKit, and AppKit patterns required to build accessible iOS, iPadOS, and macOS apps.
.accessibilityLabel..accessibilityAddTraits (never direct assignment). For binary custom controls such as favorite/star buttons, prefer a real Toggle; otherwise expose toggle behavior with .accessibilityAddTraits(.isToggle) and a current state value without putting the control type in the label..accessibilityAdjustableAction; UIKit custom adjustable controls also need the .adjustable trait.@ScaledMetric, adaptive layouts).VoiceOver reads element properties in a fixed, non-configurable order:
Label -> Value -> Trait -> Hint
Design your labels, values, and hints with this reading order in mind.
See references/a11y-patterns.md for detailed SwiftUI modifier examples (labels, hints, traits, grouping, custom controls, adjustable actions, and custom actions).
Focus management is where most apps fail. When a sheet, alert, or popover is dismissed, VoiceOver focus MUST return to the element that triggered it.
This section is about accessibility focus for assistive technologies. For keyboard focus, directional focus, focusSection(), scene-focused values, and UIFocusGuide, use the focus-engine skill.
When triaging broad focus bugs, still call out accessibility traversal separately: accessibility element order and grouping in the view hierarchy directly affect VoiceOver swipe order, Switch Control scan order, Voice Control overlay targeting, and Full Keyboard Access reachability review. Route keyboard-focus implementation to focus-engine, but keep this traversal impact in ios-accessibility.
@AccessibilityFocusState (iOS 15+)@AccessibilityFocusState is a property wrapper that reads and writes the current accessibility focus. It works with Bool for single-target focus or an optional Hashable enum for multi-target focus.
struct ContentView: View {
@State private var showSheet = false
@AccessibilityFocusState private var focusOnTrigger: Bool
var body: some View {
Button("Open Settings") { showSheet = true }
.accessibilityFocused($focusOnTrigger)
.sheet(isPresented: $showSheet) {
SettingsSheet()
.onDisappear {
// Slight delay allows the transition to complete before moving focus
Task { @MainActor in
try? await Task.sleep(for: .milliseconds(100))
focusOnTrigger = true
}
}
}
}
}
enum A11yFocus: Hashable {
case nameField
case emailField
case submitButton
}
struct FormView: View {
@AccessibilityFocusState private var focus: A11yFocus?
var body: some View {
Form {
TextField("Name", text: $name)
.accessibilityFocused($focus, equals: .nameField)
TextField("Email", text: $email)
.accessibilityFocused($focus, equals: .emailField)
Button("Submit") { validate() }
.accessibilityFocused($focus, equals: .submitButton)
}
}
func validate() {
if name.isEmpty {
focus = .nameField // Move VoiceOver to the invalid field
}
}
}
Custom overlay views need the .isModal trait to trap VoiceOver focus and an escape action for dismissal:
CustomDialog()
.accessibilityAddTraits(.isModal)
.accessibilityAction(.escape) { dismiss() }
Test dismissal as part of the modal contract: users must be able to dismiss the overlay with the relevant assistive-technology escape gesture or keyboard escape path, and focus should return to the trigger or next logical target.
When you need to announce changes or move focus imperatively in UIKit contexts:
// Announce a status change (e.g., "Item deleted", "Upload complete")
UIAccessibility.post(notification: .announcement, argument: "Upload complete")
// Partial screen update -- move focus to a specific element
UIAccessibility.post(notification: .layoutChanged, argument: targetView)
// Full screen transition -- move focus to the new screen
UIAccessibility.post(notification: .screenChanged, argument: newScreenView)
Scale text with system text styles. Scale non-text dimensions too: icon sizes, spacing, control heights, and custom hit-region dimensions should use @ScaledMetric(relativeTo:) where they need to track text size.
See references/a11y-patterns.md for Dynamic Type and adaptive layout examples, including @ScaledMetric and minimum tap target patterns.
Rotors let VoiceOver users quickly navigate to specific content types. Add custom rotors for content-heavy screens. See references/a11y-patterns.md for complete rotor examples.
Always respect these environment values:
@Environment(\.accessibilityReduceMotion) var reduceMotion
@Environment(\.accessibilityReduceTransparency) var reduceTransparency
@Environment(\.colorSchemeContrast) var contrast // .standard or .increased
@Environment(\.legibilityWeight) var legibilityWeight // .regular or .bold
Replace movement-based animations with crossfades or no animation:
withAnimation(reduceMotion ? nil : .spring()) {
showContent.toggle()
}
content.transition(reduceMotion ? .opacity : .slide)
Review every moving transition, including row deletion, quantity changes, sheet or checkout presentation, and modal dismissal. Under Reduce Motion, replace slide, bounce, parallax, spring, and large spatial transitions with opacity changes, instant state changes, or no animation.
// Solid backgrounds when transparency is reduced
.background(reduceTransparency ? Color(.systemBackground) : Color(.systemBackground).opacity(0.85))
// Stronger colors when contrast is increased
.foregroundStyle(contrast == .increased ? .primary : .secondary)
// Bold weight when system bold text is enabled
.fontWeight(legibilityWeight == .bold ? .bold : .regular)
// Decorative images: hidden from VoiceOver
Image(decorative: "background-pattern")
Image("visual-divider").accessibilityHidden(true)
// Icon next to text: Label handles this automatically
Label("Settings", systemImage: "gear")
// Icon-only buttons: MUST have an accessibility label
Button(action: { }) {
Image(systemName: "gear")
}
.accessibilityLabel("Settings")
Treat an image as decorative only when it adds no information beyond adjacent accessible text. If it communicates a product variant, state, chart point, user-generated content, or another distinguishing detail, provide a meaningful description instead of hiding it.
Voice Control relies on accessibility labels to generate spoken tap targets. If a label is missing or unspeakable, Voice Control cannot target the element.
accessibilityInputLabels as pre-freeze accessibility work for long, awkward, localized, acronym-heavy, or commonly shortened spoken labels; do not defer it as polish. Voice Control and Full Keyboard Access use these. List alternatives in descending order of importance.accessibilityInputLabels broadly to any visible target whose primary label is hard to say, including repeated row actions, quantity controls, account/settings links, media controls, and localized labels with acronyms or product names.See references/a11y-patterns.md for accessibilityInputLabels examples and speakable label guidelines.
Switch Control scans accessibility elements sequentially in reading order. Proper grouping and custom actions are critical for usability.
.accessibilityElement(children: .combine) to reduce scan stops..accessibilityAction(named:) custom actions instead — Switch Control presents them as a menu.accessibilityFrame accurately reflects the tappable region (for point scanning mode).See references/a11y-patterns.md for custom action and grouping examples.
Full Keyboard Access (iOS/iPadOS 13.4+) lets users navigate and operate an app with a hardware keyboard.
This skill covers the accessibility review surface: whether all controls are reachable, clearly labeled, visibly focused, and operable without touch. If the bug is Tab traversal, skipped custom cards, .focusable(), @FocusState, focusSection(), directional movement, scene-focused values, tvOS focus behavior, or UIFocusGuide, route implementation to the focus-engine skill first. Keep only the accessibility finding here.
See references/a11y-patterns.md for Full Keyboard Access audit checks.
Explicitly assess how accessibility element order and grouping affect traversal outcomes: VoiceOver swipe order, Switch Control scan order, Voice Control overlay targeting, and Full Keyboard Access reachability review can all break when grouping/order differs from visual or task order. Missing labels, duplicate labels, excessive row children, hidden custom controls, or grouping that does not match the visual/task order can make traversal confusing across all of them. Keep implementation mechanics for keyboard or directional routing in focus-engine; keep the accessibility impact and ordering audit here.
Assistive Access provides a simplified interface for users with cognitive disabilities. Apps should support this mode:
// Check if Assistive Access is active (iOS 18+)
@Environment(\.accessibilityAssistiveAccessEnabled) var isAssistiveAccessEnabled
var body: some View {
if isAssistiveAccessEnabled {
SimplifiedContentView()
} else {
FullContentView()
}
}
Key guidelines:
When working with UIKit views:
isAccessibilityElement = true on meaningful custom views.accessibilityLabel on all interactive elements without visible text..insert() and .remove() for trait modification (not direct assignment).accessibilityViewIsModal = true on custom overlay views to trap focus..announcement for transient status messages..layoutChanged with a target view for partial screen updates..screenChanged for full screen transitions.// UIKit trait modification
customButton.accessibilityTraits.insert(.button)
customButton.accessibilityTraits.remove(.staticText)
// Modal overlay
overlayView.accessibilityViewIsModal = true
AppKit accessibility uses NSAccessibilityProtocol and related role-specific protocols to describe accessible elements. Standard AppKit controls already provide much of this behavior; customize labels, values, roles, and actions only when the defaults are insufficient.
NSView subclasses, adopt the appropriate role-specific accessibility behavior and return the correct role, label, value, and actions.NSAccessibilityElement for accessible items that are not backed by their own NSView.NSAccessibility notifications when state changes need to be announced to assistive apps.final class FavoriteToggleView: NSView {
var isFavorite = false {
didSet {
NSAccessibility.post(element: self, notification: .valueChanged)
}
}
override func isAccessibilityElement() -> Bool { true }
override func accessibilityRole() -> NSAccessibility.Role? { .button }
override func accessibilityLabel() -> String? { "Favorite" }
override func accessibilityValue() -> Any? { isFavorite ? "On" : "Off" }
override func accessibilityPerformPress() -> Bool {
isFavorite.toggle()
return true
}
}
See references/a11y-patterns.md for AppKit examples including NSAccessibilityElement and announcement notifications.
See references/a11y-patterns.md for UIKit and AppKit accessibility patterns and custom content examples.
ProductRow(product: product)
.accessibilityCustomContent("Price", product.formattedPrice)
.accessibilityCustomContent("Rating", "\(product.rating) out of 5")
.accessibilityCustomContent(
"Availability",
product.inStock ? "In stock" : "Out of stock",
importance: .high // .high reads automatically with the element
)
For App Store accessibility nutrition labels, product-page claims, or App Store Connect accessibility answers, read references/nutrition-labels.md.
Before recommending a claim, require evidence that users can complete all common tasks with that feature on the relevant device type. Use a structured common-task by accessibility-feature matrix, include media transcripts when captions for audio-only content are relevant, and explicitly warn that App Store accessibility answers must stay accurate and must not be treated as marketing claims.
Use XCUIElement accessibility attributes to write UI tests that verify accessibility properties:
func testProductRowAccessibility() throws {
let app = XCUIApplication()
app.launch()
let productCell = app.cells["product-organic-apples"]
XCTAssertTrue(productCell.exists)
XCTAssertTrue(productCell.isEnabled)
// Verify the label is set and meaningful
XCTAssertFalse(productCell.label.isEmpty)
// Verify a specific element has the expected label
let favoriteButton = productCell.buttons["Favorite"]
XCTAssertTrue(favoriteButton.exists)
XCTAssertTrue(favoriteButton.isEnabled)
}
Key XCUIElementAttributes properties for accessibility verification: label, identifier, value, isEnabled, hasFocus, isSelected, placeholderValue, title.
Test dismissal focus restoration:
func testSheetDismissReturnsFocus() throws {
let app = XCUIApplication()
app.launch()
let triggerButton = app.buttons["Open Settings"]
triggerButton.tap()
// Dismiss the sheet
let doneButton = app.buttons["Done"]
doneButton.tap()
// Verify focus returns to trigger (in accessibility-focused testing)
XCTAssertTrue(triggerButton.hasFocus)
}
.accessibilityAddTraits(.isButton)..accessibilityElement(children: .combine)..accessibilityLabel("Settings button") reads as "Settings button, button." Omit the type.Image-only button MUST have .accessibilityLabel.accessibilityReduceMotion before movement animations..font(.system(size: 16)) ignores Dynamic Type. Use .font(.body) or similar text styles.frame(minWidth: 44, minHeight: 44) and .contentShape()..isModal on overlays: Custom modals without .accessibilityAddTraits(.isModal) let VoiceOver escape.For every user-facing view, verify:
.accessibilityAddTraits.accessibilityAdjustableAction or UIKit .adjustableImage(decorative:) or .accessibilityHidden(true)).accessibilityElement(children: .combine).isModal trait and escape action@ScaledMetric, system fonts, adaptive layouts)Sendable when passed across concurrency boundariesaccessibilityInputLabels provided for elements with long or awkward primary labelsdevelopment
Implement, review, or improve data visualizations using Swift Charts. Use when building bar, line, area, point, pie, donut, or iOS 26 3D charts; when adding chart selection, scrolling, annotations, axes, scales, legends, or foregroundStyle grouping; when plotting functions with BarPlot, LinePlot, AreaPlot, PointPlot, Chart3D, or SurfacePlot; or when creating heat maps, Gantt charts, grouped bars, sparklines, threshold lines, or spatial visualizations.
data-ai
Select, implement, or migrate between app architecture patterns for Apple platform apps. Use when choosing between MV (Model-View with @Observable), MVVM, MVI, TCA (The Composable Architecture), Clean Architecture, VIPER, or Coordinator patterns; when evaluating architecture fit for a feature's complexity; when migrating from one pattern to another; or when reviewing whether an app's current architecture is appropriate. Scoped to Apple-platform patterns using Swift 6.3, SwiftUI, and UIKit.
development
Apply Swift API Design Guidelines to name, label, and document Swift APIs. Covers argument label rules (prepositional phrase rule, grammatical phrase rule, first-label omission), mutating/nonmutating pair naming (-ed/-ing participle pattern, form- prefix, sort/sorted, formUnion/union), side-effect naming (noun for pure, verb for mutating), documentation comment structure (summary by declaration kind, O(1) complexity rule), clarity at call site, role-based naming, protocol naming (-able/-ible/-ing), default arguments over method families, casing conventions, and terminology. Use when designing new Swift APIs, reviewing naming and argument labels, writing documentation comments, or refactoring for call site clarity.
development
Implement, review, or improve in-app purchases and subscriptions using StoreKit 2. Use when building paywalls with SubscriptionStoreView or ProductView, processing transactions with Product and Transaction APIs, verifying entitlements, handling purchase flows (consumable, non-consumable, auto-renewable), implementing offer codes or promotional/win-back/introductory offers, managing subscription status and renewal state, setting up StoreKit testing with configuration files, or integrating Family Sharing, Ask to Buy, refund handling, and billing retry logic.