SwiftUI Accessibility Patterns Reference
SwiftUI Accessibility Patterns Reference
Section titled “SwiftUI Accessibility Patterns Reference”Table of Contents
Section titled “Table of Contents”- Core Principle
- Dynamic Type and @ScaledMetric
- Accessibility Traits
- Decorative Images
- Element Grouping
- Custom Controls
- Summary Checklist
Core Principle
Section titled “Core Principle”Prefer Button over onTapGesture for tappable elements. Button provides VoiceOver support, focus handling, and proper traits for free.
Dynamic Type and @ScaledMetric
Section titled “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
Section titled “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
Section titled “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
Section titled “Element Grouping”.combine – Auto-join child labels
Section titled “.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
Section titled “.ignore – Manual label for container”HStack { Text(item.name) Spacer() Text(item.price)}.accessibilityElement(children: .ignore).accessibilityLabel("\(item.name), \(item.price)").contain – Semantic grouping
Section titled “.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
Section titled “Custom Controls”Adjustable controls (increment/decrement)
Section titled “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
Section titled “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
Section titled “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
Section titled “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