accessibility-patterns
npx skills add https://github.com/kaakati/rails-enterprise-dev --skill accessibility-patterns
Agent 安装分布
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 |