Help us improve
Share bugs, ideas, or general feedback.
From ios-craft
Accessibility implementation and auditing. Use when adding VoiceOver support, Dynamic Type, accessibility labels, or auditing existing views. Walks through making an app usable for everyone.
npx claudepluginhub ildunari/kosta-plugins --plugin ios-craftHow this skill is triggered — by the user, by Claude, or both
Slash command
/ios-craft:ios-accessibility-engineerThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
Guide the user through making their app accessible to everyone, including people who use VoiceOver, have limited vision, motor impairments, or cognitive differences.
Applies platform accessibility best practices to SwiftUI, UIKit, and AppKit code, covering VoiceOver, Dynamic Type, Reduce Motion, and all Nutrition Label categories. Ensures a11y from first draft in UI writing, editing, or review.
Implements and audits accessibility in iOS/macOS apps with SwiftUI and UIKit. Covers VoiceOver, Switch Control, Dynamic Type, focus management, custom rotors, and XCTest a11y testing.
Validates WCAG 2.1 compliance for iOS apps through accessibility tree analysis, VoiceOver testing, contrast ratios, and semantic checks.
Share bugs, ideas, or general feedback.
Guide the user through making their app accessible to everyone, including people who use VoiceOver, have limited vision, motor impairments, or cognitive differences.
Beyond being the right thing to do:
VoiceOver is iOS's built-in screen reader. It reads the screen aloud and lets users navigate by touch.
How VoiceOver navigation works:
Every interactive element needs three things:
| Property | What VoiceOver reads | Example |
|---|---|---|
| Label | The name of the element | "Profile photo" |
| Value | Current state (if applicable) | "Selected" or "50%" |
| Hint | What happens when activated | "Double tap to open profile" |
See references/voiceover-guide.md for the complete implementation guide.
SwiftUI:
// Basic label
Image(systemName: "heart.fill")
.accessibilityLabel("Favorite")
// Label + hint
Button("Buy") {
purchase()
}
.accessibilityLabel("Buy \(item.name)")
.accessibilityHint("Double tap to add to cart")
// Custom value
Slider(value: $volume, in: 0...100)
.accessibilityValue("\(Int(volume)) percent")
// Traits
Text("Welcome")
.accessibilityAddTraits(.isHeader)
// Hide decorative elements
Image("decorative-line")
.accessibilityHidden(true)
UIKit:
button.accessibilityLabel = "Favorite"
button.accessibilityHint = "Double tap to add to favorites"
button.accessibilityTraits = .button
// For images that convey information
imageView.isAccessibilityElement = true
imageView.accessibilityLabel = "Chart showing upward trend"
// For decorative images
decorativeView.isAccessibilityElement = false
Common traits:
| Trait | When to use |
|---|---|
.button | Tappable elements (applied automatically to Button) |
.isHeader | Section headers (critical for navigation) |
.isSelected | Currently selected item in a group |
.link | Opens a URL or navigates away |
.image | Non-decorative images |
.staticText | Read-only text (default for Text/UILabel) |
.adjustable | Sliders, steppers — swipe up/down to adjust |
.notEnabled | Disabled controls |
Dynamic Type lets users choose their preferred text size. Your app should respect it.
SwiftUI (works automatically with system fonts):
// These automatically scale with Dynamic Type
Text("Hello")
.font(.body) // Scales
Text("Title")
.font(.title) // Scales
// Custom fonts need explicit scaling
Text("Custom")
.font(.custom("Avenir", size: 16, relativeTo: .body))
// Fixed size (opt out — use sparingly)
Text("Badge")
.font(.system(size: 12))
.dynamicTypeSize(...DynamicTypeSize.xxxLarge) // Cap maximum size
UIKit:
label.font = UIFont.preferredFont(forTextStyle: .body)
label.adjustsFontForContentSizeCategory = true
// Custom font with scaling
let customFont = UIFont(name: "Avenir", size: 16)!
label.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: customFont)
label.adjustsFontForContentSizeCategory = true
Layout rules for Dynamic Type:
Minimum contrast ratios (WCAG AA):
Check contrast programmatically:
extension UIColor {
/// Returns the relative luminance of the color
var luminance: CGFloat {
var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0
getRed(&r, green(&g, blue: &b, alpha: nil)
func adjust(_ c: CGFloat) -> CGFloat {
c <= 0.03928 ? c / 12.92 : pow((c + 0.055) / 1.055, 2.4)
}
return 0.2126 * adjust(r) + 0.7152 * adjust(g) + 0.0722 * adjust(b)
}
/// Contrast ratio between two colors (1:1 to 21:1)
func contrastRatio(with other: UIColor) -> CGFloat {
let l1 = max(luminance, other.luminance)
let l2 = min(luminance, other.luminance)
return (l1 + 0.05) / (l2 + 0.05)
}
}
Support Increase Contrast setting:
// Check if user has "Increase Contrast" enabled
if UIAccessibility.isDarkerSystemColorsEnabled {
// Use higher contrast color variants
}
// SwiftUI
@Environment(\.colorSchemeContrast) var contrast
// contrast == .increased → use higher contrast colors
Some users experience motion sickness from animations. Respect their preference.
SwiftUI:
@Environment(\.accessibilityReduceMotion) var reduceMotion
var body: some View {
Circle()
.animation(reduceMotion ? nil : .spring(), value: isExpanded)
}
// Or use the built-in modifier
Text("Hello")
.transition(.opacity) // Crossfade instead of slide when reduce motion is on
UIKit:
if UIAccessibility.isReduceMotionEnabled {
// Use simple fade instead of complex animation
UIView.animate(withDuration: 0.2) {
view.alpha = 1
}
} else {
// Full spring animation
UIView.animate(withDuration: 0.5, delay: 0, usingSpringWithDamping: 0.7, initialSpringVelocity: 0.5) {
view.transform = .identity
}
}
Rules:
Group related elements so VoiceOver reads them as a unit:
SwiftUI:
// Group a card's elements into a single VoiceOver element
HStack {
Image(item.icon)
VStack(alignment: .leading) {
Text(item.name)
Text(item.price)
}
}
.accessibilityElement(children: .combine)
// VoiceOver reads: "Coffee icon, Latte, $4.50"
// Or create a completely custom reading
HStack { ... }
.accessibilityElement(children: .ignore)
.accessibilityLabel("\(item.name), \(item.price)")
.accessibilityAddTraits(.isButton)
.accessibilityHint("Double tap to add to order")
Custom navigation order:
VStack {
header
content
footer
}
.accessibilityElement(children: .contain)
.accessibilitySortPriority(1) // Higher = read first
Xcode's Accessibility Inspector helps you audit your app without being a VoiceOver expert.
How to open it: Xcode → Open Developer Tool → Accessibility Inspector
Three key features:
Inspection mode (crosshair icon): Hover over any element to see its accessibility properties (label, value, traits, frame)
Audit (triangle icon): Automatically scans the current screen for issues:
Settings (gear icon): Test accessibility settings without changing device settings:
Testing workflow:
See references/audit-checklist.md for the complete 15-item audit checklist.
Quick pre-release check:
accessibilityLabel to the same text as a button's title — redundant, VoiceOver already reads button titlesaccessibilityHidden(true) — VoiceOver reads "Image" for unlabeled images