accessibility-patterns

📁 kaakati/rails-enterprise-dev 📅 Jan 25, 2026
10
总安装量
6
周安装量
#30997
全站排名
安装命令
npx skills add https://github.com/kaakati/rails-enterprise-dev --skill accessibility-patterns

Agent 安装分布

antigravity 5
claude-code 5
opencode 5
codex 5
windsurf 4

Skill 文档

Accessibility Patterns — Expert Decisions

Expert decision frameworks for accessibility choices. Claude knows accessibilityLabel and VoiceOver — this skill provides judgment calls for element grouping, label strategies, and compliance trade-offs.


Decision Trees

Element Grouping Strategy

How should VoiceOver read this content?
├─ Logically related (card, cell, profile)
│  └─ Combine: .accessibilityElement(children: .combine)
│     Read as single unit
│
├─ Each part independently actionable
│  └─ Keep separate
│     User needs to interact with each
│
├─ Container with multiple actions
│  └─ Combine + custom actions
│     Single element with .accessibilityAction
│
├─ Decorative image with text
│  └─ Combine, image hidden
│     Image adds no meaning
│
└─ Image conveys different info than text
   └─ Keep separate with distinct labels
      Both need to be announced

The trap: Combining elements that have different actions. User can’t interact with individual parts.

Label vs Hint Decision

What should be in label vs hint?
├─ What the element IS
│  └─ Label
│     "Play button", "Submit form"
│
├─ What happens when activated
│  └─ Hint (only if not obvious)
│     "Double tap to start playback"
│
├─ Current state
│  └─ Value
│     "50 percent", "Page 3 of 10"
│
└─ Control behavior
   └─ Traits
      .isButton, .isSelected, .isHeader

Dynamic Type Layout Strategy

How should layout adapt to larger text?
├─ Simple HStack (icon + text)
│  └─ Stay horizontal
│     Icons scale with text
│
├─ Complex HStack (image + multi-line)
│  └─ Stack vertically at xxxLarge
│     Check @Environment(\.dynamicTypeSize)
│
├─ Fixed-height cells
│  └─ Self-sizing
│     Remove height constraints
│
└─ Toolbar/navigation elements
   └─ Consider overflow menu
      Or scroll at extreme sizes

Reduce Motion Response

What happens when Reduce Motion is enabled?
├─ Transition between screens
│  └─ Instant or simple fade
│     No slide/zoom animations
│
├─ Loading indicators
│  └─ Static or minimal
│     No bouncing/spinning
│
├─ Autoplay video/animation
│  └─ Don't autoplay
│     User controls playback
│
├─ Parallax/motion effects
│  └─ Disable completely
│     Can cause vestibular issues
│
└─ Essential animation (progress)
   └─ Keep but simplify
      Linear, no bounce

NEVER Do

VoiceOver Labels

NEVER include element type in labels:

// ❌ Redundant — VoiceOver announces "Submit button, button"
Button("Submit") { }
    .accessibilityLabel("Submit button")

// ✅ VoiceOver announces "Submit, button"
Button("Submit") { }
    .accessibilityLabel("Submit")

// ❌ Redundant — "Profile image, image"
Image("profile")
    .accessibilityLabel("Profile image")

// ✅ Describe what the image shows
Image("profile")
    .accessibilityLabel("John Doe's profile photo")

NEVER use generic labels:

// ❌ User has no idea what this does
Button(action: deleteItem) {
    Image(systemName: "trash")
}
.accessibilityLabel("Button")

// ❌ Still not helpful
Button(action: deleteItem) {
    Image(systemName: "trash")
}
.accessibilityLabel("Icon")

// ✅ Describe the action
Button(action: deleteItem) {
    Image(systemName: "trash")
}
.accessibilityLabel("Delete \(item.name)")

NEVER forget to label icon-only buttons:

// ❌ VoiceOver says nothing useful
Button(action: share) {
    Image(systemName: "square.and.arrow.up")
}
// VoiceOver: "Button" (no label!)

// ✅ Always label icon buttons
Button(action: share) {
    Image(systemName: "square.and.arrow.up")
}
.accessibilityLabel("Share")

Element Visibility

NEVER hide interactive elements from accessibility:

// ❌ User can't access this control
Button("Settings") { }
    .accessibilityHidden(true)  // Why would you do this?

// ✅ Every interactive element must be accessible
// Only hide truly decorative elements
Image("decorative-pattern")
    .accessibilityHidden(true)  // This is OK — adds nothing

NEVER leave decorative images accessible:

// ❌ VoiceOver reads meaningless "image"
Image("background-gradient")
// VoiceOver: "Image"

// ✅ Hide decorative elements
Image("background-gradient")
    .accessibilityHidden(true)

Dynamic Type

NEVER use fixed font sizes for user content:

// ❌ Doesn't respect user's text size preference
Text("Hello, World!")
    .font(.system(size: 16))  // Never scales!

// ✅ Use Dynamic Type styles
Text("Hello, World!")
    .font(.body)  // Scales automatically

// ✅ Custom font with scaling
Text("Custom")
    .font(.custom("MyFont", size: 16, relativeTo: .body))

NEVER truncate text at larger sizes without alternative:

// ❌ Content disappears at larger text sizes
Text(longContent)
    .lineLimit(2)
    .font(.body)
// At xxxLarge, user sees "Lorem ips..."

// ✅ Allow expansion or provide full content path
Text(longContent)
    .lineLimit(dynamicTypeSize >= .xxxLarge ? nil : 2)
    .font(.body)

// Or use "Read more" expansion

Reduce Motion

NEVER ignore reduce motion for essential navigation:

// ❌ User with vestibular disorders feels sick
.transition(.slide)
// Reduce Motion enabled, but still slides

// ✅ Respect reduce motion
@Environment(\.accessibilityReduceMotion) var reduceMotion

.transition(reduceMotion ? .opacity : .slide)

NEVER autoplay video when reduce motion is enabled:

// ❌ Autoplay ignores user preference
VideoPlayer(player: player)
    .onAppear { player.play() }  // Always autoplays

// ✅ Check reduce motion
VideoPlayer(player: player)
    .onAppear {
        if !UIAccessibility.isReduceMotionEnabled {
            player.play()
        }
    }

Color and Contrast

NEVER convey information by color alone:

// ❌ Color-blind users can't distinguish states
Circle()
    .fill(isOnline ? .green : .red)  // Only color differs

// ✅ Use shape/icon in addition to color
HStack {
    Circle()
        .fill(isOnline ? .green : .red)
    Text(isOnline ? "Online" : "Offline")
}
// Or
Image(systemName: isOnline ? "checkmark.circle.fill" : "xmark.circle.fill")
    .foregroundColor(isOnline ? .green : .red)

Essential Patterns

Accessible Card Component

struct AccessibleCard: View {
    let item: Item
    let onTap: () -> Void
    let onDelete: () -> Void
    let onShare: () -> Void

    var body: some View {
        VStack(alignment: .leading, spacing: 8) {
            Text(item.title)
                .font(.headline)

            Text(item.description)
                .font(.body)
                .foregroundColor(.secondary)

            Text(item.date, style: .date)
                .font(.caption)
        }
        .padding()
        .background(Color(.systemBackground))
        .cornerRadius(12)

        // Combine all text for VoiceOver
        .accessibilityElement(children: .combine)
        .accessibilityLabel("\(item.title). \(item.description). \(item.date.formatted())")
        .accessibilityAddTraits(.isButton)

        // Custom actions instead of hidden buttons
        .accessibilityAction(.default) { onTap() }
        .accessibilityAction(named: "Delete") { onDelete() }
        .accessibilityAction(named: "Share") { onShare() }
    }
}

Dynamic Type Adaptive Layout

struct AdaptiveProfileView: View {
    @Environment(\.dynamicTypeSize) private var dynamicTypeSize

    let user: User

    var body: some View {
        if dynamicTypeSize.isAccessibilitySize {
            // Vertical layout for accessibility sizes
            VStack(alignment: .leading, spacing: 12) {
                profileImage
                userInfo
            }
        } else {
            // Horizontal layout for standard sizes
            HStack(spacing: 16) {
                profileImage
                userInfo
            }
        }
    }

    private var profileImage: some View {
        Image(user.avatarName)
            .resizable()
            .scaledToFill()
            .frame(width: imageSize, height: imageSize)
            .clipShape(Circle())
            .accessibilityLabel("\(user.name)'s profile photo")
    }

    private var userInfo: some View {
        VStack(alignment: .leading, spacing: 4) {
            Text(user.name)
                .font(.headline)
            Text(user.title)
                .font(.subheadline)
                .foregroundColor(.secondary)
        }
    }

    private var imageSize: CGFloat {
        dynamicTypeSize.isAccessibilitySize ? 80 : 60
    }
}

extension DynamicTypeSize {
    var isAccessibilitySize: Bool {
        self >= .accessibility1
    }
}

Reduce Motion Wrapper

struct MotionSafeAnimation<Content: View>: View {
    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    let fullAnimation: Animation
    let reducedAnimation: Animation
    let content: Content

    init(
        full: Animation = .spring(),
        reduced: Animation = .linear(duration: 0.2),
        @ViewBuilder content: () -> Content
    ) {
        self.fullAnimation = full
        self.reducedAnimation = reduced
        self.content = content()
    }

    var body: some View {
        content
            .animation(reduceMotion ? reducedAnimation : fullAnimation, value: UUID())
    }
}

// Usage
struct AnimatedButton: View {
    @State private var isPressed = false
    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    var body: some View {
        Button("Tap Me") { }
            .scaleEffect(isPressed ? 0.95 : 1.0)
            .animation(reduceMotion ? nil : .spring(), value: isPressed)
            .onLongPressGesture(minimumDuration: .infinity, pressing: { pressing in
                isPressed = pressing
            }, perform: {})
    }
}

Accessible Form

struct AccessibleForm: View {
    @State private var email = ""
    @State private var password = ""
    @State private var emailError: String?
    @FocusState private var focusedField: Field?

    enum Field: Hashable {
        case email, password
    }

    var body: some View {
        Form {
            Section {
                TextField("Email", text: $email)
                    .focused($focusedField, equals: .email)
                    .textContentType(.emailAddress)
                    .keyboardType(.emailAddress)
                    .accessibilityLabel("Email address")
                    .accessibilityValue(email.isEmpty ? "Empty" : email)

                if let error = emailError {
                    Text(error)
                        .font(.caption)
                        .foregroundColor(.red)
                        .accessibilityLabel("Error: \(error)")
                }

                SecureField("Password", text: $password)
                    .focused($focusedField, equals: .password)
                    .textContentType(.password)
                    .accessibilityLabel("Password")
                    .accessibilityHint("Minimum 8 characters")
            }

            Button("Sign In") {
                signIn()
            }
            .accessibilityLabel("Sign in")
            .accessibilityHint("Double tap to sign in with entered credentials")
        }
        .onSubmit {
            switch focusedField {
            case .email:
                focusedField = .password
            case .password:
                signIn()
            case nil:
                break
            }
        }
        .onChange(of: emailError) { _, error in
            if error != nil {
                // Announce error to VoiceOver
                UIAccessibility.post(notification: .announcement,
                    argument: "Error: \(error ?? "")")
            }
        }
    }
}

Quick Reference

WCAG AA Requirements

Criterion Requirement iOS Implementation
1.4.3 Contrast 4.5:1 normal, 3:1 large Use semantic colors
1.4.4 Resize Text 200% without loss Dynamic Type support
2.1.1 Keyboard All functionality VoiceOver navigation
2.4.7 Focus Visible Clear focus indicator @FocusState
2.5.5 Target Size 44x44pt minimum .frame(minWidth:minHeight:)

Accessibility Traits

Trait When to Use
.isButton Custom tappable views
.isHeader Section titles
.isSelected Currently selected item
.isLink Navigates to URL
.isImage Meaningful images
.playsSound Audio triggers
.startsMediaSession Video/audio playback
.adjustable Swipe up/down to change value

Focus Notifications

Notification Use Case
.screenChanged Major UI change, new screen
.layoutChanged Minor UI update
.announcement Status message
.pageScrolled Scroll position changed

Red Flags

Smell Problem Fix
“Button” in label Redundant Remove type from label
Icon without label Inaccessible Add accessibilityLabel
.accessibilityHidden(true) on control Can’t interact Remove or rethink
.font(.system(size:)) Doesn’t scale Use .font(.body)
Color-only status Color-blind exclusion Add icon or text
Animation ignores reduceMotion Vestibular issues Check environment
Decorative image without hidden Noisy VoiceOver accessibilityHidden(true)
Combined elements with separate actions Can’t interact individually Keep separate or use custom actions