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

360 lines
8.2 KiB
Markdown

---
name: SwiftUI Accessibility
description: Dynamic Type support and VoiceOver accessibility implementation
globs: ["**/*.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:
```swift
// 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:
```swift
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:
```swift
// 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
```swift
// 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:
```swift
// 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:
```swift
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:
```swift
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:
```swift
// 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:
```swift
// 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")
}
```
### Group Related Elements
Reduce navigation complexity by grouping related content:
```swift
// 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:
```swift
ItemRow(item: item)
.accessibilityAction(named: "Delete") {
deleteItem(item)
}
.accessibilityAction(named: "Edit") {
editItem(item)
}
.accessibilityAction(named: "Share") {
shareItem(item)
}
```
### Accessibility Announcements
Announce important state changes:
```swift
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:
```swift
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
```swift
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
```swift
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
```swift
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