Get the FREE Ultimate OpenClaw Setup Guide β†’

ios-accessibility

npx machina-cli add skill dpearson2699/swift-ios-skills/ios-accessibility --openclaw
Files (1)
SKILL.md
17.5 KB

iOS Accessibility β€” SwiftUI and UIKit

Every user-facing view must be usable with VoiceOver, Switch Control, Voice Control, Full Keyboard Access, and other assistive technologies. This skill covers the patterns and APIs required to build accessible iOS apps.

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).
  3. Decorative images MUST be hidden from assistive technologies.
  4. Sheet and dialog dismissals MUST return VoiceOver focus to the trigger element.
  5. All tap targets MUST be at least 44x44 points.
  6. Dynamic Type MUST be supported everywhere (system fonts, @ScaledMetric, adaptive layouts).
  7. No information conveyed by color alone -- always provide text or icon alternatives.
  8. 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

Labels, Values, and Hints

// Label: the primary description VoiceOver reads
Button(action: { }) {
    Image(systemName: "heart.fill")
}
.accessibilityLabel("Favorite")

// Hint: describes the result of activation (read after a pause)
Button("Submit")
    .accessibilityHint("Submits the form and sends your feedback")

// Value: the current state for sliders, toggles, progress indicators
Slider(value: $volume, in: 0...100)
    .accessibilityValue("\(Int(volume)) percent")
  • Label: Short, descriptive noun or noun phrase. Do not include the element type (VoiceOver announces the trait separately).
  • Value: Current state. Update dynamically as the value changes.
  • Hint: Starts with a verb, describes the result. Only add when the action is not obvious from the label and trait.

Input Labels

Use accessibilityInputLabels to provide alternative labels for Voice Control:

Button("Go") { }
    .accessibilityInputLabels(["Go", "Start", "Begin"])

Traits

Use .accessibilityAddTraits and .accessibilityRemoveTraits to modify traits. NEVER use direct trait assignment -- it overwrites the element's built-in traits.

TraitUse For
.isButtonCustom tappable views that are not Button
.isHeaderSection headers (enables rotor heading navigation)
.isLinkElements that navigate to external content
.isSelectedCurrently selected tab, segment, or radio button
.isImageMeaningful images
.isToggleCustom toggle controls
.isModalTrap VoiceOver focus inside a custom overlay
.updatesFrequentlyTimers, live counters, real-time displays
.isSearchFieldCustom search inputs
.startsMediaSessionElements that begin audio/video playback
// WRONG: overwrites Button's built-in .isButton trait
Button("Go") { }
    .accessibilityTraits(.updatesFrequently)

// CORRECT: adds to existing traits
Button("Go") { }
    .accessibilityAddTraits(.updatesFrequently)

// Remove a trait when needed
Text("Not really a header anymore")
    .accessibilityRemoveTraits(.isHeader)

Element Grouping

Reduce VoiceOver swipe count by grouping related elements into a single accessibility stop.

// .combine: merge children into one VoiceOver element
// VoiceOver concatenates child labels automatically
HStack {
    Image(systemName: "person.circle")
    VStack {
        Text("John Doe")
        Text("Engineer")
    }
}
.accessibilityElement(children: .combine)

// .ignore: replace children with a completely custom label
HStack {
    Image(systemName: "envelope")
    Text("inbox@example.com")
}
.accessibilityElement(children: .ignore)
.accessibilityLabel("Email: inbox@example.com")

// .contain: keep children individually navigable but logically grouped
VStack {
    Text("Order #1234")
    Button("Track") { }
}
.accessibilityElement(children: .contain)

List rows should use .accessibilityElement(children: .combine) unless individual child elements require separate focus (e.g., a row with multiple independently interactive controls).

Custom Controls with accessibilityRepresentation

Use .accessibilityRepresentation (iOS 15+) for custom controls that map to standard SwiftUI equivalents. The framework generates correct accessibility elements from the representation automatically, including traits, adjustable actions, and values.

HStack {
    Text("Dark Mode")
    Circle()
        .fill(isDark ? .green : .gray)
        .onTapGesture { isDark.toggle() }
}
.accessibilityRepresentation {
    Toggle("Dark Mode", isOn: $isDark)
}
// Custom slider control
VStack {
    SliderTrack(value: value) // Custom visual implementation
}
.accessibilityRepresentation {
    Slider(value: $value, in: 0...100) {
        Text("Volume")
    }
}

Adjustable Controls

For controls that support increment/decrement (star ratings, steppers):

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
        }
    }

VoiceOver users swipe up/down to adjust. The .accessibilityAdjustableAction modifier automatically adds the .adjustable trait.

Custom Actions

Replace hidden swipe actions, context menus, or long-press gestures with named accessibility actions so VoiceOver users can discover and invoke them:

MessageRow(message: message)
    .accessibilityAction(named: "Reply") { reply(to: message) }
    .accessibilityAction(named: "Delete") { delete(message) }
    .accessibilityAction(named: "Flag") { flag(message) }

System-level actions:

PlayerView()
    .accessibilityAction(.magicTap) { togglePlayPause() }
    .accessibilityAction(.escape) { dismiss() }
  • Magic Tap (two-finger double-tap): Toggle the most relevant action (play/pause, answer/end call).
  • Escape (two-finger Z-scrub): Dismiss the current modal or go back.

Sort Priority

Control VoiceOver reading order among sibling elements. Higher values are read first:

ZStack {
    Image("photo").accessibilitySortPriority(0)     // Read third
    Text("Credit").accessibilitySortPriority(1)      // Read second
    Text("Breaking News").accessibilitySortPriority(2) // Read first
}

Only use when the default visual order produces a confusing reading sequence.

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.

@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() }

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

@ScaledMetric (iOS 14+)

@ScaledMetric scales numeric values (spacing, icon sizes, padding) proportionally with the user's preferred text size. It takes a base value and optionally a text style to scale relative to.

@ScaledMetric(relativeTo: .title) private var iconSize: CGFloat = 24
@ScaledMetric private var spacing: CGFloat = 8

var body: some View {
    HStack(spacing: spacing) {
        Image(systemName: "star.fill")
            .frame(width: iconSize, height: iconSize)
        Text("Favorite")
    }
}

Adaptive Layouts

Switch from horizontal to vertical layout at large accessibility text sizes:

@Environment(\.dynamicTypeSize) var dynamicTypeSize

var body: some View {
    if dynamicTypeSize.isAccessibilitySize {
        VStack(alignment: .leading) { icon; textContent }
    } else {
        HStack { icon; textContent }
    }
}

Use dynamicTypeSize.isAccessibilitySize (iOS 15+) rather than comparing against specific cases -- it covers all five accessibility size categories.

Minimum Tap Targets

Every tappable element must be at least 44x44 points:

Button(action: { }) {
    Image(systemName: "plus")
        .frame(minWidth: 44, minHeight: 44)
}
.contentShape(Rectangle())

Custom Rotors

Rotors let VoiceOver users quickly navigate to specific content types. Add custom rotors for content-heavy screens:

List(items) { item in
    ItemRow(item: item)
}
.accessibilityRotor("Unread") {
    ForEach(items.filter { !$0.isRead }) { item in
        AccessibilityRotorEntry(item.title, id: item.id)
    }
}
.accessibilityRotor("Flagged") {
    ForEach(items.filter { $0.isFlagged }) { item in
        AccessibilityRotorEntry(item.title, id: item.id)
    }
}

Users access rotors by rotating two fingers on screen. The system provides built-in rotors for headings, links, and form controls. Custom rotors extend this with app-specific navigation.

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 .bold

Reduce Motion

Replace movement-based animations with crossfades or no animation:

withAnimation(reduceMotion ? nil : .spring()) {
    showContent.toggle()
}
content.transition(reduceMotion ? .opacity : .slide)

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")

Assistive Access (iOS 26+)

iOS 26 introduces AssistiveAccess for supporting Assistive Access mode in scenes. Use the AssistiveAccess scene modifier to provide simplified versions of your app's UI for users with cognitive disabilities. Test your app with Assistive Access enabled in Settings > Accessibility > Assistive Access.

UIKit Accessibility Patterns

When working with UIKit views:

  • Set isAccessibilityElement = true on meaningful custom views.
  • Set accessibilityLabel on all interactive elements without visible text.
  • Use .insert() and .remove() for trait modification (not direct assignment).
  • Set accessibilityViewIsModal = true on custom overlay views to trap focus.
  • Post .announcement for transient status messages.
  • Post .layoutChanged with a target view for partial screen updates.
  • Post .screenChanged for full screen transitions.
// UIKit trait modification
customButton.accessibilityTraits.insert(.button)
customButton.accessibilityTraits.remove(.staticText)

// Modal overlay
overlayView.accessibilityViewIsModal = true

Accessibility Custom Content

Use accessibilityCustomContent for supplementary details that should not clutter the primary label. VoiceOver users access custom content via the "More Content" rotor:

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
    )

Common Mistakes

  1. Direct trait assignment: .accessibilityTraits(.isButton) overwrites all existing traits. 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
  • 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 .isModal trait 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)
  • 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 Sendable when passed across concurrency boundaries

Apple Documentation Reference

For the latest API details, use the apple-docs MCP server when available:

  • searchAppleDocumentation with queries like "accessibility SwiftUI" or "VoiceOver"
  • fetchAppleDocumentation with paths like /documentation/swiftui/view-accessibility or /documentation/swiftui/scaledmetric
  • Apple's modifier reference covers labels, values, hints, actions, traits, focus, rotors, and custom content

Source

git clone https://github.com/dpearson2699/swift-ios-skills/blob/main/skills/ios-accessibility/SKILL.mdView on GitHub

Overview

Learn how to build apps that work with VoiceOver, Switch Control, and other assistive technologies on iOS and macOS using SwiftUI and UIKit. This skill covers labeling, traits, and accessible grouping, supports Dynamic Type with @ScaledMetric, and ensures apps respect system accessibility preferences to reach every user.

How This Skill Works

Apply SwiftUI/UIKit accessibility modifiers such as .accessibilityLabel, .accessibilityValue, .accessibilityHint, and .accessibilityAddTraits to expose meaning and state to assistive tech. Use inputLabels for Voice Control and manage focus with @AccessibilityFocusState; group related elements to reduce swipe counts and enable rotor navigation. Ensure Dynamic Type support across UI with system fonts and @ScaledMetric, and respect system preferences like Reduce Motion.

When to Use It

  • Adding VoiceOver support with accessible labels, hints, values, and traits so each control is discoverable
  • Grouping or reordering accessibility elements to improve navigation and reduce swipe counts
  • Managing focus programmatically with @AccessibilityFocusState to guide users through complex flows
  • Supporting Dynamic Type with @ScaledMetric and adaptive layouts for different text sizes
  • Auditing an app for accessibility compliance and adapting UI for assistive technologies and system preferences

Quick Start

  1. Step 1: Annotate interactive elements with accessibilityLabel and accessibilityValue as appropriate
  2. Step 2: Add accessibilityInputLabels for Voice Control and apply appropriate traits with accessibilityAddTraits
  3. Step 3: Test with VoiceOver, ensure focus returns after dismissals, and verify 44x44 targets and Dynamic Type support

Best Practices

  • Always provide an accessible label for every interactive element
  • Use accessibilityAddTraits (not direct assignment) to modify traits, and remove traits when needed
  • Hide decorative images from assistive tech with accessibilityHidden
  • Ensure dismissal of sheets/dialog returns VoiceOver focus to the trigger element
  • Verify tap targets are at least 44x44 points and test with Dynamic Type and accessibility settings

Example Use Cases

  • A custom switch control that combines a label, value, and .isToggle trait for VoiceOver
  • A form where sliders expose accessibilityValue and hints to describe the current amount
  • A media player that uses rotor actions and custom accessibility actions
  • Grouped list sections where VoiceOver rotor navigation uses .isHeader to mark sections
  • An app that respects Reduce Motion, Bold Text, and Increased Contrast preferences

Frequently Asked Questions

Add this skill to your agents
Sponsor this space

Reach thousands of developers β†—