Skip to content

SwiftUI Accessibility Patterns Reference

Prefer Button over onTapGesture for tappable elements. Button provides VoiceOver support, focus handling, and proper traits for free.

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))
}
}
}

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.

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)
HStack {
Image(systemName: "star.fill")
Text("Favorites")
Text("(\(count))")
}
.accessibilityElement(children: .combine)

VoiceOver reads all child labels as one element, separated by commas.

HStack {
Text(item.name)
Spacer()
Text(item.price)
}
.accessibilityElement(children: .ignore)
.accessibilityLabel("\(item.name), \(item.price)")
HStack {
ForEach(tabs) { tab in
TabButton(tab: tab)
}
}
.accessibilityElement(children: .contain)
.accessibilityLabel("Tab bar")

VoiceOver announces the container name when focus enters/exits.

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)
}
@Namespace private var ns
HStack {
Text("Volume")
.accessibilityLabeledPair(role: .label, id: "volume", in: ns)
Slider(value: $volume)
.accessibilityLabeledPair(role: .content, id: "volume", in: ns)
}
  • Use Button instead of onTapGesture for tappable elements
  • Use built-in text styles or Dynamic Type-aware custom fonts for text
  • Use @ScaledMetric for 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 accessibilityLabel when default labels are unclear
  • Use accessibilityRepresentation for custom controls
  • Use accessibilityAdjustableAction for increment/decrement controls
  • Ensure navigation flow is logical when using VoiceOver grouping