accessibility

Concrete accessibility implementations for SwiftUI apps on macOS and iOS. Covers: adaptive glass effects that fall back to solid backgrounds when Reduce Transparency is enabled, motion-safe animations that respect Reduce Motion, VoiceOver support (accessible labels, custom actions, rotor entries, state change announcements for both macOS and iOS), Dynamic Type with @ScaledMetric and adaptive grid layouts, keyboard navigation with FocusState and onKeyPress, Commands menus with keyboard shortcuts, high contrast mode detection, color-blind safe status indicators using accessibilityDifferentiateWithoutColor, and a complete testing checklist. Use when building any user-facing SwiftUI app to ensure it meets accessibility standards. Especially important when using Liquid Glass (which needs Reduce Transparency fallbacks) or custom animations (which need Reduce Motion handling).

Safety Notice

This listing is imported from skills.sh public index metadata. Review upstream SKILL.md and repository scripts before running.

Copy this and send it to your AI assistant to learn

Install skill "accessibility" with this command: npx skills add makgunay/claude-swift-skills/makgunay-claude-swift-skills-accessibility

Accessibility Patterns

Concrete implementations for VoiceOver, Dynamic Type, Reduce Motion, keyboard navigation, and color contrast. These patterns apply to both macOS and iOS SwiftUI apps.

Critical Rules

  • ❌ Never rely on color alone to convey information — always pair with icons or text
  • ❌ Never use glass effects without a solid fallback for Reduce Transparency
  • ❌ Never ship animations without Reduce Motion handling
  • ❌ Never skip VoiceOver labels on interactive elements
  • ✅ Every interactive element must have an accessibility label
  • ✅ Every state change must be announced to VoiceOver
  • ✅ Every animation must respect accessibilityReduceMotion
  • ✅ Every glass effect must have a accessibilityReduceTransparency fallback

Adaptive Glass Effect

Glass effects become unreadable when Reduce Transparency is enabled. Always provide a solid fallback.

struct AdaptiveGlassModifier: ViewModifier {
    @Environment(\.accessibilityReduceTransparency) var reduceTransparency

    let style: GlassEffectStyle
    let shape: AnyShape

    func body(content: Content) -> some View {
        if reduceTransparency {
            content
                .background(Color(.systemBackground))
                .clipShape(shape)
        } else {
            content
                .glassEffect(style, in: shape)
        }
    }
}

extension View {
    func adaptiveGlass(
        _ style: GlassEffectStyle = .regular,
        in shape: some Shape = .rect
    ) -> some View {
        modifier(AdaptiveGlassModifier(style: style, shape: AnyShape(shape)))
    }
}

// Usage
Text("Accessible Glass")
    .padding()
    .adaptiveGlass(.regular, in: .rect(cornerRadius: 12))

Motion-Safe Animations

ViewModifier Approach

struct MotionSafeAnimation<V: Equatable>: ViewModifier {
    @Environment(\.accessibilityReduceMotion) var reduceMotion

    let animation: Animation
    let value: V

    func body(content: Content) -> some View {
        content.animation(reduceMotion ? .none : animation, value: value)
    }
}

extension View {
    func motionSafeAnimation<V: Equatable>(
        _ animation: Animation = .default,
        value: V
    ) -> some View {
        modifier(MotionSafeAnimation(animation: animation, value: value))
    }
}

Conditional withAnimation

struct MorphingCard: View {
    @State private var isExpanded = false
    @Environment(\.accessibilityReduceMotion) var reduceMotion

    var body: some View {
        VStack { /* content */ }
        .onChange(of: isExpanded) { _, newValue in
            if reduceMotion {
                // Instant change, no animation
            } else {
                withAnimation(.spring(response: 0.3)) {
                    // Animated change
                }
            }
        }
    }
}

VoiceOver Support

Accessible Labels

struct PromptRow: View {
    let prompt: Prompt

    var body: some View {
        HStack {
            Image(systemName: prompt.icon)
            VStack(alignment: .leading) {
                Text(prompt.title)
                Text(prompt.preview)
                    .font(.caption)
            }
        }
        .accessibilityElement(children: .combine)
        .accessibilityLabel("\(prompt.title), \(prompt.category?.name ?? "uncategorized")")
        .accessibilityHint("Double tap to copy prompt")
        .accessibilityAddTraits(.isButton)
    }
}

Custom Actions

Expose multiple actions without requiring complex gesture discovery.

struct PromptCard: View {
    let prompt: Prompt
    var onCopy: () -> Void
    var onEdit: () -> Void
    var onDelete: () -> Void

    var body: some View {
        VStack { /* card content */ }
            .accessibilityElement(children: .combine)
            .accessibilityLabel(prompt.title)
            .accessibilityActions {
                Button("Copy to clipboard") { onCopy() }
                Button("Edit") { onEdit() }
                Button("Delete", role: .destructive) { onDelete() }
            }
    }
}

Rotor Support

Let VoiceOver users navigate by category or filter.

struct PromptListView: View {
    let prompts: [Prompt]
    @State private var favorites: Set<UUID> = []

    var body: some View {
        List(prompts) { prompt in
            PromptRow(prompt: prompt)
        }
        .accessibilityRotor("Favorites") {
            ForEach(prompts.filter { favorites.contains($0.id) }) { prompt in
                AccessibilityRotorEntry(prompt.title, id: prompt.id)
            }
        }
        .accessibilityRotor("Categories") {
            ForEach(Category.all) { category in
                AccessibilityRotorEntry(category.name, id: category.id)
            }
        }
    }
}

Announce State Changes

Always announce clipboard operations and other invisible state changes.

func copyPrompt(_ prompt: Prompt) {
    ClipboardService.shared.copy(prompt.content)

    #if os(iOS)
    UIAccessibility.post(
        notification: .announcement,
        argument: "Copied \(prompt.title) to clipboard"
    )
    #else
    NSAccessibility.post(
        element: NSApp.mainWindow as Any,
        notification: .announcementRequested,
        userInfo: [.announcement: "Copied \(prompt.title) to clipboard"]
    )
    #endif
}

Dynamic Type

Scaled Metrics

struct PromptDetailView: View {
    let prompt: Prompt
    @ScaledMetric(relativeTo: .body) var iconSize = 24
    @ScaledMetric(relativeTo: .title) var headerPadding = 16

    var body: some View {
        VStack(alignment: .leading, spacing: headerPadding) {
            HStack {
                Image(systemName: prompt.icon)
                    .font(.system(size: iconSize))
                Text(prompt.title)
                    .font(.title)
            }
            Text(prompt.content)
                .font(.body)
        }
        .padding()
    }
}

Adaptive Grid Layouts

Reduce columns as text size increases to prevent truncation.

struct AdaptivePromptGrid: View {
    let prompts: [Prompt]
    @Environment(\.dynamicTypeSize) var dynamicTypeSize

    var columns: [GridItem] {
        if dynamicTypeSize >= .accessibility1 {
            return [GridItem(.flexible())]                                    // 1 column
        } else if dynamicTypeSize >= .xxxLarge {
            return [GridItem(.flexible()), GridItem(.flexible())]             // 2 columns
        } else {
            return Array(repeating: GridItem(.flexible()), count: 3)          // 3 columns
        }
    }

    var body: some View {
        LazyVGrid(columns: columns, spacing: 16) {
            ForEach(prompts) { prompt in
                PromptCard(prompt: prompt)
            }
        }
    }
}

Keyboard Navigation (macOS)

Focus Management with FocusState

struct QuickAccessView: View {
    @FocusState private var focusedPrompt: UUID?
    let prompts: [Prompt]

    var body: some View {
        VStack {
            ForEach(prompts) { prompt in
                PromptRow(prompt: prompt)
                    .focused($focusedPrompt, equals: prompt.id)
            }
        }
        .onKeyPress(.upArrow) {
            moveFocus(direction: -1)
            return .handled
        }
        .onKeyPress(.downArrow) {
            moveFocus(direction: 1)
            return .handled
        }
        .onKeyPress(.return) {
            if let id = focusedPrompt { selectPrompt(id: id) }
            return .handled
        }
    }

    func moveFocus(direction: Int) {
        guard let current = focusedPrompt,
              let index = prompts.firstIndex(where: { $0.id == current }) else {
            focusedPrompt = prompts.first?.id
            return
        }
        let newIndex = index + direction
        if prompts.indices.contains(newIndex) {
            focusedPrompt = prompts[newIndex].id
        }
    }
}

Commands Menu with Keyboard Shortcuts

struct PromptManagerCommands: Commands {
    var body: some Commands {
        CommandGroup(after: .newItem) {
            Button("New Prompt") { }
                .keyboardShortcut("n", modifiers: .command)
            Button("New Category") { }
                .keyboardShortcut("n", modifiers: [.command, .shift])
        }

        CommandMenu("Prompts") {
            Button("Copy Selected") { }
                .keyboardShortcut("c", modifiers: [.command, .shift])
            Button("Quick Access") { }
                .keyboardShortcut(" ", modifiers: [.command, .shift])
        }
    }
}

Color Contrast & Color Blindness

High Contrast Mode

struct ContrastAwareText: View {
    let text: String
    @Environment(\.colorSchemeContrast) var contrast

    var body: some View {
        Text(text)
            .foregroundStyle(contrast == .increased ? .primary : .secondary)
    }
}

Color-Blind Safe Status Indicators

Always pair color with icon and optional text label.

struct StatusIndicator: View {
    let status: Status
    @Environment(\.accessibilityDifferentiateWithoutColor) var differentiateWithoutColor

    enum Status { case success, warning, error }

    var body: some View {
        HStack {
            Image(systemName: iconName)
                .foregroundStyle(color)

            if differentiateWithoutColor {
                Text(statusText)
                    .font(.caption)
            }
        }
    }

    var iconName: String {
        switch status {
        case .success: "checkmark.circle.fill"
        case .warning: "exclamationmark.triangle.fill"
        case .error: "xmark.circle.fill"
        }
    }

    var color: Color {
        switch status {
        case .success: .green
        case .warning: .yellow
        case .error: .red
        }
    }

    var statusText: String {
        switch status {
        case .success: "Success"
        case .warning: "Warning"
        case .error: "Error"
        }
    }
}

System Preferences Detection

Observable object that monitors all accessibility settings at runtime.

class AccessibilitySettings: ObservableObject {
    @Published var reduceTransparency: Bool
    @Published var reduceMotion: Bool
    @Published var differentiateWithoutColor: Bool
    @Published var isVoiceOverRunning: Bool

    init() {
        #if os(macOS)
        reduceTransparency = NSWorkspace.shared.accessibilityDisplayShouldReduceTransparency
        reduceMotion = NSWorkspace.shared.accessibilityDisplayShouldReduceMotion
        differentiateWithoutColor = NSWorkspace.shared.accessibilityDisplayShouldDifferentiateWithoutColor
        isVoiceOverRunning = NSWorkspace.shared.isVoiceOverEnabled
        #else
        reduceTransparency = UIAccessibility.isReduceTransparencyEnabled
        reduceMotion = UIAccessibility.isReduceMotionEnabled
        differentiateWithoutColor = UIAccessibility.shouldDifferentiateWithoutColor
        isVoiceOverRunning = UIAccessibility.isVoiceOverRunning
        #endif
        observeChanges()
    }

    private func observeChanges() {
        #if os(iOS)
        NotificationCenter.default.addObserver(
            forName: UIAccessibility.reduceTransparencyStatusDidChangeNotification,
            object: nil, queue: .main
        ) { [weak self] _ in
            self?.reduceTransparency = UIAccessibility.isReduceTransparencyEnabled
        }
        NotificationCenter.default.addObserver(
            forName: UIAccessibility.reduceMotionStatusDidChangeNotification,
            object: nil, queue: .main
        ) { [weak self] _ in
            self?.reduceMotion = UIAccessibility.isReduceMotionEnabled
        }
        #endif
    }
}

Testing Checklist

VoiceOver

  • All interactive elements have .accessibilityLabel
  • Custom actions available where context menus exist
  • Logical reading order (test by swiping through elements)
  • Announcements for state changes (copy, delete, save)
  • Rotors configured for key navigation paths

Dynamic Type

  • All text scales via system fonts (no hardcoded sizes)
  • Layout adapts at accessibility sizes (grid → single column)
  • No truncation at .accessibility3 size
  • Touch targets remain ≥ 44pt at all sizes

Reduce Motion

  • All withAnimation calls gated on accessibilityReduceMotion
  • Glass morphing effects simplified or removed
  • No essential information conveyed only through animation
  • Transitions fall back to opacity-only

Reduce Transparency

  • Glass effects have solid background fallback
  • Sufficient contrast maintained without blur
  • Readability preserved on all backgrounds

Keyboard (macOS)

  • All features accessible via keyboard alone
  • Logical tab/focus order
  • Visible focus indicators
  • Standard shortcuts work (⌘N, ⌘F, ⌘,)
  • Custom shortcuts registered in Commands menu

Source Transparency

This detail page is rendered from real SKILL.md content. Trust labels are metadata-based hints, not a safety guarantee.

Related Skills

Related by shared tags or category signals.

General

macos-permissions

No summary provided by upstream source.

Repository SourceNeeds Review
General

macos-app-structure

No summary provided by upstream source.

Repository SourceNeeds Review
General

swiftui-core

No summary provided by upstream source.

Repository SourceNeeds Review
General

liquid-glass

No summary provided by upstream source.

Repository SourceNeeds Review