ai-docs/assets/skills/swiftui-accessibility/SKILL.md
Matt Bruce 88e4402d38 fix: use tool-agnostic ~/.agents/ as default install path
Copilot, Claude, Cursor, and others all read from ~/.agents/.
The npx skills CLI handles fan-out to tool-specific directories.
2026-02-11 12:13:20 -06:00

8.2 KiB

name description globs
SwiftUI Accessibility Dynamic Type support and VoiceOver accessibility implementation
**/*.swift

Accessibility: Dynamic Type and VoiceOver

Accessibility is not optional. All apps must support Dynamic Type and VoiceOver.

Dynamic Type

Always Support Dynamic Type

Use system text styles that scale automatically:

// GOOD - Scales with Dynamic Type
Text("Title")
    .font(.title)

Text("Body text")
    .font(.body)

Text("Caption")
    .font(.caption)

Use @ScaledMetric for Custom Dimensions

When you need custom sizes that should scale with Dynamic Type:

struct CustomCard: View {
    @ScaledMetric(relativeTo: .body) private var iconSize: CGFloat = 24
    @ScaledMetric(relativeTo: .body) private var spacing: CGFloat = 12
    @ScaledMetric(relativeTo: .title) private var headerHeight: CGFloat = 44
    
    var body: some View {
        VStack(spacing: spacing) {
            Image(systemName: "star")
                .font(.system(size: iconSize))
            Text("Content")
        }
    }
}

Choose Appropriate relativeTo Styles

Match the scaling behavior to the content's purpose:

Content Type relativeTo
Body content spacing .body
Title decorations .title
Caption elements .caption
Large headers .largeTitle

Fixed Sizes (Use Sparingly)

Only use fixed sizes when absolutely necessary, and document the reason:

// Fixed size for app icon badge - must match system badge size
private let badgeSize: CGFloat = 24  // Fixed: matches system notification badge

// Fixed for external API requirements
private let avatarUploadSize: CGFloat = 256  // Fixed: server requires exactly 256x256

Prefer System Text Styles

// GOOD - System styles
.font(.body)
.font(.headline)
.font(.title)
.font(.caption)

// AVOID - Custom sizes that don't scale
.font(.system(size: 14))

// IF you must use custom sizes, use ScaledMetric
@ScaledMetric private var customSize: CGFloat = 14
.font(.system(size: customSize))

VoiceOver

Accessibility Labels

All interactive elements must have meaningful labels:

// GOOD - Descriptive label
Button { } label: {
    Image(systemName: "trash")
}
.accessibilityLabel("Delete item")

// GOOD - Context-aware label
Button { } label: {
    Image(systemName: "heart.fill")
}
.accessibilityLabel(item.isFavorite ? "Remove from favorites" : "Add to favorites")

Accessibility Values

Use for dynamic state that changes:

Slider(value: $volume)
    .accessibilityLabel("Volume")
    .accessibilityValue("\(Int(volume * 100)) percent")

Toggle(isOn: $isEnabled) {
    Text("Notifications")
}
.accessibilityValue(isEnabled ? "On" : "Off")

Accessibility Hints

Describe what happens when the user interacts:

Button("Submit") { }
    .accessibilityLabel("Submit order")
    .accessibilityHint("Double-tap to place your order and proceed to payment")

NavigationLink(value: item) {
    ItemRow(item: item)
}
.accessibilityHint("Opens item details")

Accessibility Traits

Use traits to convey element type and behavior:

// Button trait (usually automatic)
Text("Tap me")
    .onTapGesture { }
    .accessibilityAddTraits(.isButton)

// Header trait for section headers
Text("Settings")
    .font(.headline)
    .accessibilityAddTraits(.isHeader)

// Selected state
ItemRow(item: item)
    .accessibilityAddTraits(isSelected ? .isSelected : [])

// Image trait removal for decorative images
Image("decorative-background")
    .accessibilityHidden(true)

Hide Decorative Elements

Hide elements that don't provide meaningful information:

// Decorative separator
Divider()
    .accessibilityHidden(true)

// Background decoration
Image("pattern")
    .accessibilityHidden(true)

// Redundant icon next to text
HStack {
    Image(systemName: "envelope")
        .accessibilityHidden(true)  // Label conveys the meaning
    Text("Email")
}

Reduce navigation complexity by grouping related content:

// GOOD - Single VoiceOver element
HStack {
    Image(systemName: "person")
    VStack(alignment: .leading) {
        Text(user.name)
        Text(user.email)
            .font(.caption)
    }
}
.accessibilityElement(children: .combine)

// OR create a completely custom accessibility representation
.accessibilityElement(children: .ignore)
.accessibilityLabel("\(user.name), \(user.email)")

Accessibility Actions

Add custom actions for complex interactions:

ItemRow(item: item)
    .accessibilityAction(named: "Delete") {
        deleteItem(item)
    }
    .accessibilityAction(named: "Edit") {
        editItem(item)
    }
    .accessibilityAction(named: "Share") {
        shareItem(item)
    }

Accessibility Announcements

Announce important state changes:

func completeTask() {
    task.isCompleted = true
    
    // Announce the change
    AccessibilityNotification.Announcement("Task completed")
        .post()
}

func showError(_ message: String) {
    errorMessage = message
    
    // Announce errors immediately
    AccessibilityNotification.Announcement(message)
        .post()
}

Accessibility Focus

Control focus for important UI changes:

struct ContentView: View {
    @AccessibilityFocusState private var isSearchFocused: Bool
    @State private var showingSearch = false
    
    var body: some View {
        VStack {
            if showingSearch {
                TextField("Search", text: $searchText)
                    .accessibilityFocused($isSearchFocused)
            }
            
            Button("Search") {
                showingSearch = true
                isSearchFocused = true  // Move focus to search field
            }
        }
    }
}

Common Patterns

Cards and List Items

struct ItemCard: View {
    let item: Item
    
    var body: some View {
        VStack(alignment: .leading) {
            Text(item.title)
                .font(.headline)
            Text(item.subtitle)
                .font(.subheadline)
                .foregroundStyle(.secondary)
            
            HStack {
                Label("\(item.likes)", systemImage: "heart")
                Label("\(item.comments)", systemImage: "bubble.right")
            }
            .font(.caption)
        }
        // Combine into single VoiceOver element
        .accessibilityElement(children: .combine)
        // Add meaningful summary
        .accessibilityLabel("\(item.title), \(item.subtitle)")
        .accessibilityValue("\(item.likes) likes, \(item.comments) comments")
    }
}

Interactive Charts

Chart {
    ForEach(data) { point in
        LineMark(x: .value("Date", point.date), y: .value("Value", point.value))
    }
}
.accessibilityLabel("Sales chart")
.accessibilityValue("Showing data from \(startDate) to \(endDate)")
.accessibilityHint("Swipe up or down to hear individual data points")
.accessibilityChartDescriptor(self)

Custom Controls

struct RatingControl: View {
    @Binding var rating: Int
    
    var body: some View {
        HStack {
            ForEach(1...5, id: \.self) { star in
                Image(systemName: star <= rating ? "star.fill" : "star")
                    .onTapGesture { rating = star }
            }
        }
        .accessibilityElement(children: .ignore)
        .accessibilityLabel("Rating")
        .accessibilityValue("\(rating) of 5 stars")
        .accessibilityAdjustableAction { direction in
            switch direction {
            case .increment:
                rating = min(5, rating + 1)
            case .decrement:
                rating = max(1, rating - 1)
            @unknown default:
                break
            }
        }
    }
}

Testing Accessibility

Enable VoiceOver in Simulator

  1. Settings → Accessibility → VoiceOver
  2. Or use Accessibility Inspector (Xcode → Open Developer Tool)

Audit Checklist

  • All interactive elements have labels
  • Dynamic content announces changes
  • Decorative elements are hidden
  • Text scales with Dynamic Type
  • Touch targets are at least 44pt
  • Color is not the only indicator of state
  • Groups reduce navigation complexity