
Ios Accessibility
- 3.3k installs
- 944 repo stars
- Updated July 15, 2026
- dpearson2699/swift-ios-skills
ios-accessibility is an agent skill that implements and reviews VoiceOver, Dynamic Type, and assistive technology support in SwiftUI, UIKit, and AppKit apps.
About
ios-accessibility is a Swift iOS and macOS skill for making every user-facing view usable with VoiceOver, Switch Control, Voice Control, Full Keyboard Access, and related assistive technologies. Core principles require accessible labels on interactive elements, correct traits via accessibilityAddTraits, adjustable actions on custom steppers, hidden decorative images, focus restoration after sheet dismissals, forty-four point minimum tap targets, Dynamic Type support, and respect for Reduce Motion and Increase Contrast preferences. VoiceOver reads label, value, trait, then hint in fixed order. SwiftUI coverage spans accessibilityLabel, hints, traits, grouping, custom rotors, and AccessibilityFocusState for returning focus to triggers after sheets close. UIKit and AppKit patterns, custom content, nutrition label guidance, and XCTest accessibility checks are included. The skill routes keyboard focus engine work separately but documents how traversal order affects VoiceOver swipe and Switch Control scan paths. A review checklist and common mistakes section support pre-ship audits and App Store Connect accessibility answers.
- Covers SwiftUI, UIKit, and AppKit accessibility modifiers and patterns.
- Requires labels, traits, adjustable actions, and forty-four point tap targets.
- AccessibilityFocusState restores VoiceOver focus after sheet or dialog dismissals.
- Dynamic Type via system fonts and ScaledMetric with adaptive layouts.
- Includes XCTest checks and App Store Accessibility Nutrition Label guidance.
Ios Accessibility by the numbers
- 3,333 all-time installs (skills.sh)
- +158 installs in the week ending Jul 29, 2026 (Skillselion tracking)
- Ranked #42 of 1,039 Mobile Development skills by installs in the Skillselion catalog
- Security screen: LOW risk (skills.sh audit)
- Data as of Jul 31, 2026 (Skillselion catalog sync)
ios-accessibility capabilities & compatibility
- Capabilities
- swiftui accessibility modifier patterns · focus restoration with accessibilityfocusstate · dynamic type and system preference support
- Use cases
- testing · frontend
- Platforms
- macOS
What ios-accessibility says it does
Every interactive element MUST have an accessible label.
All tap targets MUST be at least 44x44 points.
npx skills add https://github.com/dpearson2699/swift-ios-skills --skill ios-accessibilityAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 3.3k |
|---|---|
| repo stars | ★ 944 |
| Security audit | 3 / 3 scanners passed |
| Last updated | July 15, 2026 |
| Repository | dpearson2699/swift-ios-skills ↗ |
How do I make my iOS or macOS app pass VoiceOver, keyboard, and App Store accessibility requirements?
Implement, review, or improve VoiceOver, Dynamic Type, keyboard, and App Store accessibility compliance in SwiftUI, UIKit, and AppKit apps.
Who is it for?
iOS and macOS developers shipping SwiftUI or UIKit apps who need systematic accessibility implementation and review.
Skip if: Skip for web-only projects or when keyboard focus engine routing is the only need without assistive traversal work.
When should I use this skill?
User works on VoiceOver labels, AccessibilityFocusState, Dynamic Type, a11y audits, or App Store accessibility nutrition labels.
What you get
Accessible labels, traits, focus restoration, Dynamic Type layouts, and test coverage aligned with Apple a11y guidelines.
- accessibility review report
- per-issue remediation guidance
Files
iOS/macOS Accessibility - SwiftUI, UIKit, and AppKit
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.
Contents
- Core Principles
- How VoiceOver Reads Elements
- SwiftUI Accessibility Modifiers
- Focus Management
- Dynamic Type
- Custom Rotors
- System Accessibility Preferences
- Decorative Content
- Voice Control
- Switch Control
- Full Keyboard Access
- Assistive Access (iOS 18+)
- UIKit Accessibility Patterns
- AppKit Accessibility Patterns
- Accessibility Custom Content
- App Store Accessibility Nutrition Labels
- Testing Accessibility
- Common Mistakes
- Review Checklist
- References
---
Core Principles
1. Every interactive element MUST have an accessible label. If no visible text exists, add .accessibilityLabel. 2. Every custom control MUST have correct traits via .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. 3. Custom adjustable controls such as quantity steppers MUST expose adjustable behavior with .accessibilityAdjustableAction; UIKit custom adjustable controls also need the .adjustable trait. 4. Decorative images MUST be hidden from assistive technologies. 5. Sheet and dialog dismissals MUST return VoiceOver focus to the trigger element. 6. All tap targets MUST be at least 44x44 points. 7. Dynamic Type MUST be supported everywhere (system fonts, @ScaledMetric, adaptive layouts). 8. No information conveyed by color alone -- always provide text or icon alternatives. 9. System accessibility preferences MUST be respected: Reduce Motion, Reduce Transparency, Bold Text, Increase Contrast.
How VoiceOver Reads Elements
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.
SwiftUI Accessibility Modifiers
See references/a11y-patterns.md for detailed SwiftUI modifier examples (labels, hints, traits, grouping, custom controls, adjustable actions, and custom actions).
Focus Management
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
}
}
}
}
}Multi-Target Focus with Enum
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 Modals
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.
Accessibility Notifications (UIKit)
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)Dynamic Type
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.
Custom Rotors
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.
System Accessibility Preferences
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 .boldReduce Motion
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.
Reduce Transparency, Increase Contrast, Bold Text
// 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 Content
// 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
Voice Control relies on accessibility labels to generate spoken tap targets. If a label is missing or unspeakable, Voice Control cannot target the element.
- Every interactive element MUST have a speakable accessibility label (no emoji-only, no symbol-only).
- Labels must be unique within the visible screen — duplicate labels force users to disambiguate with overlay numbers.
- Treat
accessibilityInputLabelsas 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. - Apply
accessibilityInputLabelsbroadly 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. - Test with Voice Control enabled: say "Show Names" and "Show Numbers" to verify all interactive elements are targetable.
- For Voice Control reviews, verify both overlays: "Show Names" confirms speakable labels, and "Show Numbers" confirms every visible interactive target can still be reached when names are missing, duplicated, or awkward.
See references/a11y-patterns.md for accessibilityInputLabels examples and speakable label guidelines.
Switch Control
Switch Control scans accessibility elements sequentially in reading order. Proper grouping and custom actions are critical for usability.
- Group related content with
.accessibilityElement(children: .combine)to reduce scan stops. - Every scan target should be meaningful and actionable. Decorative elements hidden from VoiceOver are also hidden from Switch Control.
- Switch Control users cannot perform swipe-to-delete, long-press, or multi-finger gestures. Expose these interactions as
.accessibilityAction(named:)custom actions instead — Switch Control presents them as a menu. - Custom controls with non-standard hit areas should ensure
accessibilityFrameaccurately reflects the tappable region (for point scanning mode).
See references/a11y-patterns.md for custom action and grouping examples.
Full Keyboard Access
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.
- Every interactive element can be reached and activated with the keyboard.
- Traversal order is logical and does not trap focus.
- Focus indicators remain visible at all contrast and text-size settings.
- Gesture-only behavior has a keyboard-operable alternative.
- App shortcuts do not override system-defined shortcuts such as Cmd+C, Cmd+V, or Cmd+Tab.
See references/a11y-patterns.md for Full Keyboard Access audit checks.
Traversal Order
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 (iOS 18+)
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:
- Reduce visual complexity: fewer controls, larger tap targets, simpler navigation
- Use clear, literal language for labels and instructions
- Minimize the number of choices presented at once
- Test with Assistive Access enabled in Settings > Accessibility > Assistive Access
UIKit Accessibility Patterns
When working with UIKit views:
- Set
isAccessibilityElement = trueon meaningful custom views. - Set
accessibilityLabelon all interactive elements without visible text. - Use
.insert()and.remove()for trait modification (not direct assignment). - Set
accessibilityViewIsModal = trueon custom overlay views to trap focus. - Post
.announcementfor transient status messages. - Post
.layoutChangedwith a target view for partial screen updates. - Post
.screenChangedfor full screen transitions.
// UIKit trait modification
customButton.accessibilityTraits.insert(.button)
customButton.accessibilityTraits.remove(.staticText)
// Modal overlay
overlayView.accessibilityViewIsModal = trueAppKit Accessibility Patterns
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.
- Prefer standard AppKit controls first — they already expose accessibility metadata and notifications.
- For custom
NSViewsubclasses, adopt the appropriate role-specific accessibility behavior and return the correct role, label, value, and actions. - Use
NSAccessibilityElementfor accessible items that are not backed by their ownNSView. - Post
NSAccessibilitynotifications 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.
Accessibility Custom Content
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
)App Store Accessibility Nutrition Labels
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.
Testing Accessibility
Manual Testing
- Accessibility Inspector (Xcode > Open Developer Tool): Audit views for missing labels, traits, and contrast issues. Run audits against the Simulator or connected device.
- VoiceOver testing: Enable in Settings > Accessibility > VoiceOver. Navigate every screen with swipe gestures.
- Voice Control testing: Enable in Settings > Accessibility > Voice Control. Say both "Show Names" and "Show Numbers"; names verify speakable labels, while numbers verify every visible interactive target is reachable even when names are duplicated, missing, or awkward.
- Full Keyboard Access testing: Enable in Settings > Accessibility > Keyboards > Full Keyboard Access. Tab through every screen and verify all interactive elements receive focus.
- Switch Control testing: Enable in Settings > Accessibility > Switch Control. Verify scan order is logical and custom actions appear for gesture-based interactions.
- Dynamic Type: Test with all text sizes in Settings > Accessibility > Display & Text Size > Larger Text.
Automated Testing with XCTest
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)
}Common Mistakes
1. Direct trait assignment: UIKit trait mutation or incorrect SwiftUI trait APIs can overwrite existing behavior. In SwiftUI, use .accessibilityAddTraits(.isButton). 2. Missing focus restoration: Dismissing sheets without returning VoiceOver focus to the trigger element. 3. Ungrouped list rows: Multiple text elements per row create excessive swipe stops. Use .accessibilityElement(children: .combine). 4. Redundant trait in labels: .accessibilityLabel("Settings button") reads as "Settings button, button." Omit the type. 5. Missing labels on icon-only buttons: Every Image-only button MUST have .accessibilityLabel. 6. Ignoring Reduce Motion: Always check accessibilityReduceMotion before movement animations. 7. Fixed font sizes: .font(.system(size: 16)) ignores Dynamic Type. Use .font(.body) or similar text styles. 8. Small tap targets: Icons without frame(minWidth: 44, minHeight: 44) and .contentShape(). 9. Color as sole indicator: Red/green for error/success without text or icon alternatives. 10. Missing `.isModal` on overlays: Custom modals without .accessibilityAddTraits(.isModal) let VoiceOver escape.
Review Checklist
For every user-facing view, verify:
- [ ] Every interactive element has an accessible label
- [ ] Custom controls use correct traits via
.accessibilityAddTraits - [ ] Adjustable custom controls expose adjustable behavior with
.accessibilityAdjustableActionor UIKit.adjustable - [ ] Decorative images are hidden (
Image(decorative:)or.accessibilityHidden(true)) - [ ] List rows group content with
.accessibilityElement(children: .combine) - [ ] Sheets and dialogs return focus to the trigger on dismiss
- [ ] Custom overlays have
.isModaltrait and escape action - [ ] All tap targets are at least 44x44 points
- [ ] Dynamic Type supported (
@ScaledMetric, system fonts, adaptive layouts) - [ ] Reduce Motion respected (no movement animations when enabled)
- [ ] Row, checkout, sheet, and modal animations have Reduce Motion alternatives
- [ ] Reduce Transparency respected (solid backgrounds when enabled)
- [ ] Increase Contrast respected (stronger foreground colors)
- [ ] No information conveyed by color alone
- [ ] Custom actions provided for swipe-to-reveal and context menu features
- [ ] Icon-only buttons have labels
- [ ] Heading traits set on section headers
- [ ] Custom accessibility types and notification payloads are
Sendablewhen passed across concurrency boundaries - [ ] Labels are speakable and unique for Voice Control (no emoji-only or duplicate labels on screen)
- [ ] Voice Control testing covers both "Show Names" and "Show Numbers"
- [ ]
accessibilityInputLabelsprovided for elements with long or awkward primary labels - [ ] Gesture-based interactions (swipe-to-delete, long-press) have accessibility custom action equivalents for Switch Control
- [ ] Full Keyboard Access reaches and activates every control without focus traps
- [ ] Element order and grouping are checked for traversal impact across VoiceOver, Switch Control, Voice Control overlays, and Full Keyboard Access review
- [ ] System keyboard shortcuts are not overridden
References
- references/a11y-patterns.md — SwiftUI and UIKit modifier examples, grouping, custom actions, rotors, Dynamic Type
- references/nutrition-labels.md — App Store Accessibility Nutrition Labels: current categories with pass/fail criteria
- references/media-accessibility.md — Captions, audio descriptions, AVMediaCharacteristic, SDH
{
"skill_name": "ios-accessibility",
"evals": [
{
"id": 0,
"name": "assistive-technology-review",
"prompt": "Review this SwiftUI shopping cart screen for accessibility before implementation: each row has an icon, product name, price, stepper-like +/- icons built with tap gestures, a swipe-to-delete action, duplicate \"Edit\" buttons, a favorite heart-only button, a checkout sheet, and product photos. Product wants VoiceOver, Voice Control, Switch Control, Dynamic Type, and Reduce Motion support. What should we fix?",
"expected_output": "A practical accessibility review that covers labels, values, traits, grouping, gesture alternatives, Voice Control input labels, Switch Control custom actions, focus restoration, Dynamic Type, decorative/content images, and Reduce Motion.",
"files": [],
"assertions": [
"Requires meaningful accessibility labels, values, and traits for icon-only buttons and custom stepper controls without putting control types like \"button\" in the label.",
"Recommends grouping each cart row with `accessibilityElement(children: .combine)` or an equivalent pattern to reduce VoiceOver and Switch Control scan stops.",
"Provides `.accessibilityAction(named:)` custom actions for swipe-to-delete, favorite, quantity changes, or other gesture-only behavior so Switch Control and VoiceOver can operate them.",
"Mentions Voice Control testing with \"Show Names\" and/or \"Show Numbers\" and recommends unique, speakable labels plus `accessibilityInputLabels` for long or awkward names.",
"Requires returning accessibility focus to the checkout trigger after dismissing the sheet with `@AccessibilityFocusState` or an equivalent UIKit notification.",
"Covers Dynamic Type and Reduce Motion using system text styles, adaptive layout, `@ScaledMetric`, and `accessibilityReduceMotion` rather than fixed fonts or motion-only feedback."
]
},
{
"id": 1,
"name": "nutrition-labels-current-categories",
"prompt": "We're preparing App Store Accessibility Nutrition Labels for a media-heavy iOS 26 app. The team wants to claim VoiceOver, Switch Control, Full Keyboard Access, Closed Captions / SDH, Audio Descriptions, Larger Text, Reduce Motion, and Increase Contrast. Audit this list against Apple's current label categories and tell us what evidence to collect before answering in App Store Connect.",
"expected_output": "A source-grounded label audit that corrects stale category names, routes non-label assistive technology work appropriately, and describes evidence needed for common-task claims.",
"files": [],
"assertions": [
"Lists the current App Store Accessibility Nutrition Label categories as VoiceOver, Voice Control, Larger Text, Dark Interface, Differentiate Without Color Alone, Sufficient Contrast, Reduced Motion, Captions, and Audio Descriptions.",
"States that Switch Control and Full Keyboard Access are important accessibility work but are not current App Store Accessibility Nutrition Label categories.",
"Renames Closed Captions / SDH to Captions and Increase Contrast to Sufficient Contrast, while also covering Audio Descriptions and Reduced Motion.",
"Explains that labels should be claimed only when users can complete all common tasks with that feature on the relevant device type.",
"Calls for evidence such as a common-task test matrix, VoiceOver and Voice Control testing, text-size testing to at least 200% where supported, contrast checks, Reduce Motion checks, and caption/audio-description track or transcript review.",
"Warns that App Store accessibility label answers must remain accurate and should not be treated as marketing claims."
]
},
{
"id": 2,
"name": "focus-boundary-routing",
"prompt": "A team asks the iOS accessibility skill to fix all focus bugs in a SwiftUI iPad app: VoiceOver focus is lost after closing a custom modal, Tab order skips a custom card, arrow-key movement between panels feels wrong, a tvOS build needs UIFocusGuide routing, and Voice Control names are duplicated. Give a concise scope review and the first fixes.",
"expected_output": "A boundary-aware accessibility focus answer that fixes assistive-technology focus and Voice Control issues while routing keyboard, directional, tvOS, and UIFocusGuide work to the focus-engine skill.",
"files": [],
"assertions": [
"Keeps VoiceOver/accessibility focus restoration after modal dismissal in ios-accessibility scope and recommends `@AccessibilityFocusState` or accessibility notifications.",
"Keeps duplicate Voice Control names in ios-accessibility scope and recommends unique, speakable labels and `accessibilityInputLabels` where appropriate.",
"Routes Tab order, `.focusable()`, `@FocusState`, arrow-key movement, focus sections, tvOS directional focus, and `UIFocusGuide` details to the focus-engine skill rather than expanding the accessibility skill into that domain.",
"Explains the distinction between accessibility focus for assistive technologies and keyboard/directional focus in the scene.",
"Still notes that accessibility element order can affect VoiceOver, Switch Control, Voice Control, and Full Keyboard Access traversal when reviewing user-facing UI."
]
}
]
}
Accessibility Patterns Reference
Contents
- Labels, Values, and Hints
- Traits and Element Grouping
- Custom Controls and Adjustable Actions
- Focus Management Patterns
- Dynamic Type and Layout
- Custom Rotors
- System Accessibility Preferences
- UIKit Accessibility Patterns
- AppKit Accessibility Patterns
- Common Mistakes Checklist
- Voice Control Patterns
- Switch Control Patterns
- Full Keyboard Access Patterns
- Automated Accessibility Testing
Labels, Values, and Hints
Button(action: { }) {
Image(systemName: "heart.fill")
}
.accessibilityLabel("Favorite")
Slider(value: $volume, in: 0...100)
.accessibilityValue("\(Int(volume)) percent")
Button("Submit")
.accessibilityHint("Submits the form and sends your feedback")Traits and Element Grouping
// Add traits without overwriting defaults
Button("Go") { }
.accessibilityAddTraits(.updatesFrequently)
// Group children into a single accessibility element
HStack {
Image(systemName: "person.circle")
VStack {
Text("John Doe")
Text("Engineer")
}
}
.accessibilityElement(children: .combine)
// Binary custom control: prefer Toggle when possible; otherwise expose toggle state
HStack {
Image(systemName: isFavorite ? "heart.fill" : "heart")
Text(product.name)
}
.onTapGesture { isFavorite.toggle() }
.accessibilityElement()
.accessibilityLabel("Favorite \(product.name)")
.accessibilityValue(isFavorite ? "On" : "Off")
.accessibilityAddTraits(.isToggle)
.accessibilityAction { isFavorite.toggle() }Custom Controls and Adjustable Actions
HStack { /* custom star rating UI */ }
.accessibilityElement()
.accessibilityLabel("Rating")
.accessibilityValue("\(rating) out of 5 stars")
.accessibilityAdjustableAction { direction in
switch direction {
case .increment: if rating < 5 { rating += 1 }
case .decrement: if rating > 1 { rating -= 1 }
@unknown default: break
}
}For custom quantity controls, steppers, ratings, sliders, or other adjustable values, prefer the native control first. If the control is custom, SwiftUI needs .accessibilityAdjustableAction; UIKit custom controls also need accessibilityTraits.insert(.adjustable).
Focus Management Patterns
@AccessibilityFocusState private var focusOnTrigger: Bool
Button("Open Settings") { showSheet = true }
.accessibilityFocused($focusOnTrigger)
.sheet(isPresented: $showSheet) {
SettingsSheet()
.onDisappear {
Task { @MainActor in
try? await Task.sleep(for: .milliseconds(100))
focusOnTrigger = true
}
}
}enum A11yFocus: Hashable { case nameField, emailField, submitButton }
@AccessibilityFocusState private var focus: A11yFocus?Dynamic Type and Layout
@ScaledMetric(relativeTo: .title) private var iconSize: CGFloat = 24
@ScaledMetric(relativeTo: .body) private var rowSpacing: CGFloat = 12
@ScaledMetric(relativeTo: .body) private var controlHeight: CGFloat = 44
@Environment(\.dynamicTypeSize) var dynamicTypeSize
var body: some View {
Group {
if dynamicTypeSize.isAccessibilitySize {
VStack(alignment: .leading) { icon; textContent }
} else {
HStack { icon; textContent }
}
}
.frame(minHeight: controlHeight)
}Use @ScaledMetric(relativeTo:) for non-text dimensions that need to track text size, including icon sizes, spacing, control heights, and custom hit-region dimensions.
Custom Rotors
List(items) { item in ItemRow(item: item) }
.accessibilityRotor("Unread") {
ForEach(items.filter { !$0.isRead }) { item in
AccessibilityRotorEntry(item.title, id: item.id)
}
}System Accessibility Preferences
@Environment(\.accessibilityReduceMotion) var reduceMotion
@Environment(\.accessibilityReduceTransparency) var reduceTransparency
@Environment(\.colorSchemeContrast) var contrast
@Environment(\.legibilityWeight) var legibilityWeightUIKit Accessibility Patterns
customButton.accessibilityTraits.insert(.button)
customButton.accessibilityTraits.remove(.staticText)
UIAccessibility.post(notification: .announcement, argument: "Upload complete")
UIAccessibility.post(notification: .layoutChanged, argument: targetView)
UIAccessibility.post(notification: .screenChanged, argument: newScreenView)AppKit Accessibility Patterns
AppKit accessibility centers on NSAccessibilityProtocol. Use standard AppKit controls when possible, then override or add accessibility behavior only where the default metadata is wrong or incomplete.
Custom NSView with role, label, value, and action
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
}
}NSAccessibilityElement for non-view items
Use NSAccessibilityElement when an accessible item has no backing NSView, such as a virtual data point in a chart or a drawn annotation.
let pointElement = NSAccessibilityElement.element(
withRole: .button,
frame: pointFrame,
label: "March revenue",
parent: chartView
)AppKit announcements and notifications
NSAccessibility.post(element: saveStatusLabel, notification: .valueChanged)
NSAccessibility.post(
element: self,
notification: .announcementRequested,
userInfo: [
.announcement: "Export complete"
]
)Use .announcementRequested when assistive apps need to announce transient status. Use state-specific notifications such as .valueChanged when the accessible value changed.
Common Mistakes Checklist
- Direct trait assignment instead of
.accessibilityAddTraits - Missing focus restoration after dismissing sheets
- Ungrouped list rows creating excessive swipe stops
- Icon-only buttons missing labels
- Ignoring Reduce Motion, Reduce Transparency, or Increase Contrast
- Fixed font sizes that break Dynamic Type
- Tap targets smaller than 44x44 points
Voice Control Patterns
Voice Control generates tap targets from accessibility labels. Labels must be speakable and unique within the visible screen.
accessibilityInputLabels (iOS 14+)
Provide shorter spoken alternatives when the primary label is long, awkward, repeated, localized, acronym-heavy, or commonly shortened in speech:
// Primary label is descriptive but long to speak
Button(action: { startWorkout() }) {
VStack {
Image(systemName: "figure.run")
Text("Start Outdoor Running Workout")
}
}
.accessibilityLabel("Start Outdoor Running Workout")
.accessibilityInputLabels(["Start Run", "Run", "Start Workout"])// Navigation link with verbose label
NavigationLink {
AccountSettingsView()
} label: {
Label("Account and Privacy Settings", systemImage: "person.circle")
}
.accessibilityInputLabels(["Account", "Settings", "Privacy"])Also consider input labels for repeated row actions, quantity controls, media controls, and product-name labels where Voice Control users are likely to speak a shorter command than the visible text.
Speakable Label Guidelines
// Bad: emoji-only, unspeakable
Button("❤️") { toggleFavorite() }
// Good: speakable label
Button(action: { toggleFavorite() }) {
Image(systemName: "heart.fill")
}
.accessibilityLabel("Favorite")
// Bad: duplicate labels on same screen
ForEach(items) { item in
Button("Edit") { edit(item) } // Voice Control can't distinguish
}
// Good: unique labels
ForEach(items) { item in
Button("Edit") { edit(item) }
.accessibilityLabel("Edit \(item.name)")
}Switch Control Patterns
Switch Control scans elements sequentially. Reduce scan stops with grouping and provide custom actions for gesture-based interactions.
Custom Actions for Gesture Alternatives
// Swipe-to-delete row: Switch Control can't swipe
TaskRow(task: task)
.accessibilityAction(named: "Complete") { completeTask(task) }
.accessibilityAction(named: "Delete") { deleteTask(task) }
.accessibilityAction(named: "Reschedule") { rescheduleTask(task) }// Long-press context menu: expose actions directly
PhotoThumbnail(photo: photo)
.contextMenu { /* ... */ }
.accessibilityAction(named: "Share") { sharePhoto(photo) }
.accessibilityAction(named: "Add to Album") { addToAlbum(photo) }
.accessibilityAction(named: "Delete") { deletePhoto(photo) }Grouping for Scan Efficiency
// Bad: 5 scan stops per row
HStack {
Image(systemName: "doc")
VStack {
Text(document.title)
Text(document.date.formatted())
}
Spacer()
Text(document.size)
Image(systemName: "chevron.right")
}
// Good: 1 scan stop per row
HStack {
Image(systemName: "doc")
VStack {
Text(document.title)
Text(document.date.formatted())
}
Spacer()
Text(document.size)
Image(systemName: "chevron.right")
}
.accessibilityElement(children: .combine)Full Keyboard Access Patterns
Full Keyboard Access review checks whether keyboard users can complete the same common tasks without touch. Keep the implementation mechanics in the focus-engine skill when the fix requires Tab-order wiring, skipped custom cards, .focusable(), SwiftUI keyboard focus state, focus sections, directional movement, tvOS focus, or UIFocusGuide.
- Every interactive control is reachable by keyboard.
- Activation works with expected keyboard input.
- Focus indicators are visible and not hidden by custom styling.
- Focus traversal is logical and does not trap users in a region.
- Gesture-only interactions have keyboard-operable alternatives.
- App shortcuts do not override system shortcuts.
- Custom controls skipped by Tab should be filed as keyboard focus implementation issues and routed to
focus-engine; keep the accessibility finding here. - Explicitly assess traversal impact: accessibility element order and grouping affect VoiceOver swipe order, Switch Control scan order, Voice Control overlay targeting, and Full Keyboard Access reachability review.
Automated Accessibility Testing
Use XCUIElement attributes to verify accessibility properties in UI tests.
Verifying Labels and Identifiers
func testAccessibilityLabels() throws {
let app = XCUIApplication()
app.launch()
// Verify buttons have meaningful labels
let settingsButton = app.buttons["Settings"]
XCTAssertTrue(settingsButton.exists, "Settings button must exist")
XCTAssertTrue(settingsButton.isEnabled, "Settings button must be enabled")
// Verify a cell groups content correctly
let productCell = app.cells.element(boundBy: 0)
XCTAssertFalse(productCell.label.isEmpty, "Product cell must have a combined label")
}Testing Focus and Selection State
func testTabNavigationOrder() throws {
let app = XCUIApplication()
app.launch()
let usernameField = app.textFields["Username"]
let passwordField = app.secureTextFields["Password"]
usernameField.tap()
XCTAssertTrue(usernameField.hasFocus)
// Tab to next field
usernameField.typeText("\t")
XCTAssertTrue(passwordField.hasFocus)
}Testing Custom Actions
func testSwipeToDeleteAlternative() throws {
let app = XCUIApplication()
app.launch()
let cell = app.cells["task-buy-groceries"]
XCTAssertTrue(cell.exists)
// Verify accessibility identifier is set for test targeting
XCTAssertEqual(cell.identifier, "task-buy-groceries")
}Media Accessibility
Accessibility patterns for audio and video content using AVFoundation.
Contents
Closed Captions and Subtitles
AVMediaCharacteristic Tags
AVFoundation uses media characteristics to identify accessibility tracks:
Docs: AVMediaCharacteristic
| Characteristic | Purpose |
|---|---|
.transcribesSpokenDialogForAccessibility | Captions/SDH — transcribes dialog for deaf or hard of hearing users |
.describesMusicAndSoundForAccessibility | Captions that include descriptions of music and sound effects |
.describesVideoForAccessibility | Audio descriptions — narrates visual content for blind or low-vision users |
.easyToRead | Simplified captions for cognitive accessibility |
.containsOnlyForcedSubtitles | Subtitles that display only when content differs from the device language |
.languageTranslation | Subtitles providing a language translation |
Selecting Accessible Media
import AVFoundation
let asset = AVURLAsset(url: videoURL)
let group = try await asset.load(.mediaSelectionGroup(forMediaCharacteristic: .legible))
if let group {
// Find the closed caption option
let ccOptions = AVMediaSelectionGroup.mediaSelectionOptions(
from: group.options,
with: .transcribesSpokenDialogForAccessibility
)
if let ccOption = ccOptions.first {
let playerItem = AVPlayerItem(asset: asset)
playerItem.select(ccOption, in: group)
}
}Audio Descriptions
let adGroup = try await asset.load(.mediaSelectionGroup(forMediaCharacteristic: .audible))
if let adGroup {
let adOptions = AVMediaSelectionGroup.mediaSelectionOptions(
from: adGroup.options,
with: .describesVideoForAccessibility
)
if let adOption = adOptions.first {
playerItem.select(adOption, in: adGroup)
}
}System Accessibility Settings
AVPlayer automatically selects captioned/described tracks when the user enables these in Settings → Accessibility → Subtitles & Captioning. You don't need manual selection unless providing a custom media selection UI.
Check user preferences:
import MediaAccessibility
let captioningEnabled = MACaptionAppearanceIsDisplayedAutomatically(.user)SwiftUI VideoPlayer
SwiftUI's VideoPlayer inherits AVPlayer's automatic accessibility track selection:
import AVKit
import SwiftUI
struct AccessibleVideoView: View {
let player: AVPlayer
var body: some View {
VideoPlayer(player: player)
.accessibilityLabel("Training video")
.accessibilityHint("Double-tap to play or pause")
}
}Custom Player Controls
When building custom video controls, ensure:
- Play/pause, seek, and volume controls are all focusable and labeled
- Current time and duration are announced on focus changes
- Captions toggle is available and labeled
- Progress slider uses
accessibilityValueto announce time position - Controls remain visible/accessible when captions overlay is active
Button(action: toggleCaptions) {
Image(systemName: captionsEnabled ? "captions.bubble.fill" : "captions.bubble")
}
.accessibilityLabel(captionsEnabled ? "Captions on" : "Captions off")
.accessibilityHint("Toggles closed captions")App Store Accessibility Nutrition Labels
App Store Connect lets you declare which accessibility features your app supports. These labels appear on the product page and help users find apps that support their needs before they download.
Docs:
Contents
- Current Label Categories
- Claim Rule
- Pass / Fail Criteria
- SwiftUI Audit Example
- Related Non-Label Accessibility Work
Current Label Categories
Apple's current App Store Accessibility Nutrition Labels are:
| Label | What It Means | Key Implementation |
|---|---|---|
| VoiceOver | Users can navigate, understand, and operate the app with VoiceOver | Concise labels, values, traits, logical order, alternatives for images/charts, accessible custom controls |
| Voice Control | Users can navigate and operate the app with voice commands | Speakable visible/accessibility labels, accessibilityInputLabels, custom actions for hidden or gesture-only behavior |
| Larger Text | Text can scale to at least 200% where supported | Dynamic Type or an equivalent in-app scaling control, layouts that avoid clipping and overlap |
| Dark Interface | The app can keep common-task UI dark | System Dark Mode or an equivalent dark mode without bright flashes in common tasks |
| Differentiate Without Color Alone | Color is not the only way to convey information | Text, shape, icon, position, or pattern alternatives for color-coded state and data |
| Sufficient Contrast | Text, icons, controls, and state indicators have enough contrast | Semantic colors, high-contrast variants, Reduce Transparency handling, contrast checks in light and dark appearances |
| Reduced Motion | Problematic motion can be reduced or replaced | Respect Reduce Motion; replace parallax, spinning, scaling, depth, and ongoing motion with fades or static states where appropriate |
| Captions | Dialogue and relevant sounds are available as text for video or audio content | Captions, SDH, subtitles, or transcripts; detect and honor system caption settings |
| Audio Descriptions | Visual time-based content has narrated descriptions | Audio description tracks or equivalent narration for video, cut scenes, and visual-only cues |
Apple states these labels appear on Apple devices running iOS 26, iPadOS 26, macOS 26, tvOS 26, visionOS 26, and watchOS 26 or later. App Store Connect asks only for labels that apply to the device type.
Claim Rule
Only claim a label when users can complete all common tasks of the app using that feature. Build a task matrix per device and test the common workflows before answering in App Store Connect.
Keep claims accurate over time and do not treat App Store accessibility answers as marketing claims. Apple notes that App Review may contact developers to update intentionally misleading or harmful accessibility labels.
Pass / Fail Criteria
VoiceOver
- Every interactive element has a concise, meaningful label.
- Labels avoid control types and state words that VoiceOver already announces.
- Images and charts provide useful descriptions or text alternatives.
- Decorative images are hidden.
- Custom controls expose role, value, action, and traversal order.
- Dynamic content that matters is announced with the appropriate accessibility notification.
Voice Control
- Common tasks work using only voice commands.
- Visible labels and Voice Control names match whenever practical.
accessibilityInputLabelsprovide short spoken alternatives for long labels.- "Show Names" and "Show Numbers" expose all interactive elements.
- Swipes, long presses, hover-only controls, and hidden actions have a speech-only path, usually through custom accessibility actions.
Larger Text
- Text reaches at least 200% of the default size where the platform supports the label.
- Main workflows avoid clipped, overlapped, or severely truncated text.
- Layouts adapt at accessibility text sizes, often by switching from horizontal to vertical composition.
- Meaningful icons or text-like graphics scale or have an equivalent perceivable alternative.
Dark Interface
- Common-task screens remain dark when the user selects a dark appearance or the app's dark setting.
- Bright loading flashes, interstitials, and modal surfaces are avoided.
- Dark mode is tested together with sufficient contrast settings.
Differentiate Without Color Alone
- Status, validation, selection, chart series, and game/team state never rely on color alone.
- Use text, symbols, shape, order, pattern, or direct labels in addition to color.
- Test important workflows with grayscale or color filters to find hidden reliance on color.
Sufficient Contrast
- Most text meets generally accepted contrast guidance, commonly 4.5:1 against its background.
- Non-text state indicators and custom controls have sufficient contrast, commonly 3:1.
- Test light mode, dark mode, Increase Contrast, Bold Text, and Reduce Transparency combinations.
- Custom colors provide high-contrast variants when semantic system colors are not enough.
Reduced Motion
- Disable or replace parallax, spinning, scaling, vortex, multi-axis, multi-speed, and depth-simulating motion when Reduce Motion is enabled.
- Stop ongoing motion such as auto-advancing carousels or provide a user control to stop it.
- Preserve meaning when replacing motion; use fades, highlights, or instant transitions for state changes.
Captions
- Video dialogue and comprehension-relevant sound effects are captioned.
- Captions are synchronized, readable, and identify speakers where needed.
- Audio-only dialogue has a transcript when time-synchronized captions do not apply.
- AVFoundation media uses appropriate characteristics such as
.transcribesSpokenDialogForAccessibilityand.describesMusicAndSoundForAccessibility.
Audio Descriptions
- Visual-only story, instructions, scene changes, on-screen text, and important cues are narrated.
- Descriptions fit natural pauses and do not obscure essential dialogue.
- AVFoundation media uses
.describesVideoForAccessibilityfor audio description options.
SwiftUI Audit Example
struct ContentView: View {
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@Environment(\.dynamicTypeSize) private var typeSize
var body: some View {
VStack {
Image("hero")
.accessibilityLabel("Mountain landscape at sunset")
Text("Welcome")
.font(.title)
Button("Get Started") { }
.accessibilityHint("Opens the onboarding flow")
}
.animation(reduceMotion ? nil : .spring(), value: showContent)
}
}Related Non-Label Accessibility Work
Switch Control and Full Keyboard Access remain important app accessibility requirements, but they are not current App Store Accessibility Nutrition Label categories. Keep their implementation guidance in SKILL.md and references/a11y-patterns.md.
Related skills
How it compares
Apple platform accessibility implementation guide, not a web WCAG checklist.
FAQ
What is the minimum tap target size?
All tap targets must be at least 44x44 points per the skill core principles.
How should focus behave after closing a sheet?
VoiceOver focus must return to the trigger element using AccessibilityFocusState with a short post-dismiss delay.
Is Ios Accessibility safe to install?
skills.sh reports 3 of 3 security scanners passed. Review the Security Audits panel on this page before installing in production.