Files
playbook/antigravity-awesome-skills/skills/swiftui-expert-skill/references/accessibility-patterns.md
T
2026-07-01 16:02:41 +00:00

5.9 KiB

SwiftUI Accessibility Patterns Reference

Table of Contents

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