SwiftUI Accessibility Patterns Reference
Table of Contents
- Core Principle
- Dynamic Type and @ScaledMetric
- Accessibility Traits
- Decorative Images
- Element Grouping
- Custom Controls
- Summary Checklist
Core Principle
Prefer Button over onTapGesture for tappable elements. Button provides VoiceOver support, focus handling, and proper traits for free.
Dynamic Type and @ScaledMetric
System text styles scale with Dynamic Type automatically. Prefer built-in styles like .largeTitle, .title, .title2, .title3, .headline, .subheadline, .body, .callout, .footnote, .caption, and .caption2 when they fit your UI:
VStack(alignment: .leading) {
Text("Inbox")
.font(.title2)
Text("3 unread messages")
.font(.body)
Text("Updated just now")
.font(.caption)
}For custom fonts, use a Dynamic Type-aware font initializer so the text still follows the user's preferred content size:
VStack(alignment: .leading) {
Text("Article")
.font(.custom("SourceSerif4-Semibold", size: 28, relativeTo: .title2))
Text("Body copy")
.font(.custom("SourceSerif4-Regular", size: 17))
}Font.custom(_:size:relativeTo:) lets you match a specific text style. Font.custom(_:size:) scales relative to the body style. Avoid fixed-size custom fonts for primary content that should respond to Dynamic Type.
For non-text numeric values like padding, spacing, and image sizes, use @ScaledMetric:
struct ProfileHeader: View {
@ScaledMetric private var avatarSize = 60.0
@ScaledMetric private var spacing = 12.0
var body: some View {
HStack(spacing: spacing) {
Image("avatar")
.resizable()
.frame(width: avatarSize, height: avatarSize)
Text("Username")
}
}
}Specify a relativeTo text style when the value should track a specific Dynamic Type style, including for images or icons that should stay proportional to nearby text:
struct StatusRow: View {
@ScaledMetric(relativeTo: .body) private var iconSize = 18.0
var body: some View {
HStack(spacing: 8) {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: iconSize))
Text("Synced")
.font(.custom("AvenirNext-Regular", size: 17, relativeTo: .body))
}
}
}Accessibility Traits
Use accessibilityAddTraits and accessibilityRemoveTraits for state-driven traits:
Text(item.title)
.accessibilityAddTraits(item.isSelected ? [.isSelected, .isButton] : .isButton)Use .disabled(true) to make VoiceOver announce "Dimmed" for non-interactive elements.
Decorative Images
Use Image(decorative:bundle:) when an asset image is purely visual and should not appear in the accessibility tree.
Image(decorative: "confetti")This is appropriate for backgrounds, flourishes, and icons that do not add meaning beyond nearby text.
If the image conveys information, keep it accessible and provide a clear label:
Image("receipt")
.accessibilityLabel("Receipt")For non-asset images, such as SF Symbols, hide decorative content with accessibilityHidden(true) instead:
Image(systemName: "sparkles")
.accessibilityHidden(true)Element Grouping
.combine -- Auto-join child labels
HStack {
Image(systemName: "star.fill")
Text("Favorites")
Text("(\(count))")
}
.accessibilityElement(children: .combine)VoiceOver reads all child labels as one element, separated by commas.
.ignore -- Manual label for container
HStack {
Text(item.name)
Spacer()
Text(item.price)
}
.accessibilityElement(children: .ignore)
.accessibilityLabel("\(item.name), \(item.price)").contain -- Semantic grouping
HStack {
ForEach(tabs) { tab in
TabButton(tab: tab)
}
}
.accessibilityElement(children: .contain)
.accessibilityLabel("Tab bar")VoiceOver announces the container name when focus enters/exits.
Custom Controls
Adjustable controls (increment/decrement)
PageControl(selectedIndex: $selectedIndex, pageCount: pageCount)
.accessibilityElement()
.accessibilityValue("Page \(selectedIndex + 1) of \(pageCount)")
.accessibilityAdjustableAction { direction in
switch direction {
case .increment:
guard selectedIndex < pageCount - 1 else { break }
selectedIndex += 1
case .decrement:
guard selectedIndex > 0 else { break }
selectedIndex -= 1
@unknown default:
break
}
}Representing custom views as native controls
When a custom view should behave like a native control for accessibility:
HStack {
Text(label)
Toggle("", isOn: $isOn)
}
.accessibilityRepresentation {
Toggle(label, isOn: $isOn)
}Label-content pairing
@Namespace private var ns
HStack {
Text("Volume")
.accessibilityLabeledPair(role: .label, id: "volume", in: ns)
Slider(value: $volume)
.accessibilityLabeledPair(role: .content, id: "volume", in: ns)
}Summary Checklist
- [ ] Use
Buttoninstead ofonTapGesturefor tappable elements - [ ] Use built-in text styles or Dynamic Type-aware custom fonts for text
- [ ] Use
@ScaledMetricfor custom values that should scale with Dynamic Type - [ ] Mark purely decorative images as decorative or hidden from accessibility
- [ ] Group related elements with
accessibilityElement(children:) - [ ] Provide
accessibilityLabelwhen default labels are unclear - [ ] Use
accessibilityRepresentationfor custom controls - [ ] Use
accessibilityAdjustableActionfor increment/decrement controls - [ ] Ensure navigation flow is logical when using VoiceOver grouping