Building VoiceOver-Friendly Custom SwiftUI Controls
Custom SwiftUI controls can look perfect yet behave poorly with VoiceOver: actions aren’t discoverable, labels don’t reflect state, or focus jumps unexpectedly. That leads to QA blockers and follow-up churn. This piece shows patterns to make custom controls narrate clearly, act predictably, and remain responsive during list scrolling and rapid focus changes.
All code in this article targets iOS 26+ and Swift 6.2 unless noted otherwise.
Why This Matters
Accessibility regressions are release blockers. When you introduce a custom control, you also define its narration, its actions, and its focus model. SwiftUI’s accessibility model differs from UIKit’s and gaps often surface only during real VoiceOver navigation.
Performance also matters. Broad state changes can trigger unnecessary updates across your view tree. In practice, this can cause scroll stutter on list-heavy screens when focus moves quickly.
Treat accessibility as part of the control’s public API, not a bolt-on to its children.
1. Model The Control As One Accessible Element
Wrap And Combine
Exposing each subview — icon, title, chevron, and badge — as separate accessible elements is noisy and brittle. Prefer a wrapper with accessibilityElement(children: .combine) and set accessibilityLabel, accessibilityValue, and traits like .isButton and .isSelected on the container.
import SwiftUI
@Observable
@MainActor
final class ToggleTagModel {
var isOn: Bool = false
var label: String = "Notifications"
}
struct ToggleTag: View {
var model: ToggleTagModel
let action: () -> Void
var body: some View {
HStack(spacing: 8) {
Image(systemName: model.isOn ? "bell.fill" : "bell.slash")
Text(model.label)
Spacer()
Image(systemName: "chevron.right")
.opacity(0.4)
}
.padding(12)
.background(model.isOn ? Color.blue.opacity(0.15) : Color.gray.opacity(0.12))
.clipShape(RoundedRectangle(cornerRadius: 10))
.contentShape(Rectangle()) // full-row hit target
.onTapGesture { action() }
.accessibilityElement(children: .combine)
.accessibilityLabel(model.label)
.accessibilityValue(model.isOn ? "On" : "Off")
.accessibilityAddTraits(model.isOn ? [.isButton, .isSelected] : [.isButton])
}
}
Decision
When the control is semantically a single unit, use .combine. If a subview is independently actionable or needs a distinct rotor target, expose separate elements.
Operational Note
Keep traits on the wrapper. Splitting traits between children and parent can lead to mismatched narration across releases and flakey tests that assert label/value strings.
2. Encode State And Intent With Hints And Actions
Map Non-Standard Gestures
Custom gestures like DragGesture or manual long-presses won’t be discoverable to VoiceOver unless you provide accessibility actions. Use accessibilityAdjustableAction for increment/decrement semantics and named actions for custom operations. Keep accessibilityValue in sync with the visible state.
import SwiftUI
struct RatingControl: View {
@State private var rating: Int = 0
let max = 5
var body: some View {
HStack {
ForEach(1...max, id: \.self) { i in
Image(systemName: i <= rating ? "star.fill" : "star")
.onTapGesture { rating = i }
}
}
.accessibilityElement(children: .combine)
.accessibilityLabel("Rating")
.accessibilityValue("\(rating) of \(max)")
.accessibilityHint("Adjust with swipe up or down")
.accessibilityAdjustableAction { direction in
switch direction {
case .increment:
if rating < max { rating += 1 }
case .decrement:
if rating > 0 { rating -= 1 }
@unknown default:
break
}
}
}
}
Decision
When your control conceptually behaves like a Stepper, Toggle, or Button, map to built-in semantics (activation or adjustable). If the behavior doesn’t match system patterns, provide a clearly named custom action.
Operational Note
Update values on the main actor so VoiceOver reads fresh state. If state changes are scheduled on background tasks, narration may lag under load. Keep accessibilityValue small, localizable, and stable.
3. Control Focus, Order, And Hit Targets
Match Visual Scan Order
Your focus order should reflect how a sighted user scans the UI. Use accessibilitySortPriority(_:) to guide reading order and hide ornaments using accessibilityHidden(true).
import SwiftUI
struct ProfileHeader: View {
let name: String
let subtitle: String
var body: some View {
HStack {
Image(systemName: "person.crop.circle.fill")
.font(.system(size: 44))
.accessibilityHidden(true)
VStack(alignment: .leading) {
Text(name)
.accessibilitySortPriority(2)
Text(subtitle)
.foregroundStyle(.secondary)
.accessibilitySortPriority(1)
}
}
.padding()
}
}
Decision
When the view tree order doesn’t match reading order (e.g., overlays, ZStacks, or conditionally inserted views), set explicit priorities. When your layout is a simple top-to-bottom stack, rely on defaults.
Operational Note
A misplaced accessibilityHidden(true) on a container hides entire subtrees and can trap focus. Keep activation points inside view bounds when using accessibilityActivationPoint(_:), especially near screen edges on compact devices.
4. Announcements, Rotors, And Scroll Semantics
Announce Meaningful Async Outcomes
When background work completes and changes the user’s task state, post an announcement with UIAccessibility.post(notification: .announcement, argument: ...).
import UIKit
enum A11y {
static func announce(_ message: String) {
UIAccessibility.post(notification: .announcement, argument: message)
}
}
Use concise, localized messages and prefer a single source of truth to avoid duplicates from both network completion and UI transitions.
Use Rotors Where They Unlock Navigation
Custom rotors can make dense UIs efficient (e.g., jump among unread items). Keep rotor entries stable and localized, and test the fallback behavior when a collection is empty.
Map Paged Content To Familiar Operations
For carousels and pagers, expose predictable next/previous operations so VoiceOver users can move between pages with standard gestures. For example, provide adjustable or clearly named actions that reflect page changes.
Operational Note
Throttle announcements per screen. Over-announcing interrupts ongoing reading and forces VoiceOver to restart utterances, which slows navigation on busy screens. Log and deduplicate.
5. Performance And State Isolation Under VoiceOver
Combine To Reduce Focus Churn
Every focus move can trigger state reads and re-renders. Combining subviews into one accessibilityElement(children: .combine) reduces churn and keeps narration atomic, especially in large lists.
import SwiftUI
@Observable
final class CellState {
var title: String
var isSelected: Bool = false
init(title: String) { self.title = title }
}
struct ListCell: View {
var state: CellState
var body: some View {
HStack {
Text(state.title)
Spacer()
Image(systemName: state.isSelected ? "checkmark.circle.fill" : "circle")
}
.contentShape(Rectangle())
.onTapGesture { state.isSelected.toggle() }
.accessibilityElement(children: .combine)
.accessibilityLabel(state.title)
.accessibilityValue(state.isSelected ? "Selected" : "Not selected")
.accessibilityAddTraits(state.isSelected ? [.isButton, .isSelected] : [.isButton])
}
}
Decision
When lists update frequently, use isolated models per cell/control. Avoid capturing the same observable model instance across distant ancestors — it broadens invalidation scopes and can cause stutter when VoiceOver moves focus rapidly.
Operational Note
Profile on device with VoiceOver enabled. Use Time Profiler to verify that state changes in one control don’t re-render unrelated siblings, and adjust model boundaries if they do.
Tradeoffs And Pitfalls
- Over-grouping can hide legitimate sub-actions. If a control has two primary actions (e.g., open details vs. toggle favorite), either split into two tappables or expose multiple accessibility actions.
- Under-grouping increases focus stops and exhausts users in long lists. Group decorative content and surface a single tappable with combined
label/value. - Rotors and custom actions add localization and testing surface area. Budget time for per-locale verification and real-device passes.
- Focus and activation points near edges can mis-hit if the point leaves bounds. Keep
accessibilityActivationPoint(_:)safely inside the control’s frame. - Compatibility differences can occur across OS updates. Pin your minimum target realistically and validate narration and actions on physical devices representative of your user base.
Validation And Observability
- XCUITest:
- Query by
accessibilityIdentifierand assertlabel/valuefor each custom control. - Verify
isHittableafter scrolling the element into view. - Use async expectations to wait for
accessibilityValuechanges after invoking adjustable or activation actions. - Instruments:
- Time Profiler on a physical device with VoiceOver enabled. Record scroll plus action flows and confirm updates don’t fan out to unrelated views.
- Allocations to spot spikes from dynamic
Text/Imagework during state churn. - Logging:
- Use
OSSignposterto bracket “announcement queued” to “announcement posted,” and add log metadata for control identifiers to correlate events and deduplicate. - Post-Release:
- Monitor
MetricKitfor animation hitches on screens with custom controls. - Gate new controls behind feature flags and enable incrementally to watch crash-free rates and support tickets before full rollout.
Practical Checklist
- Wrap each custom control and set
accessibilityElement(children: .combine). - Provide
accessibilityLabel,accessibilityValue, and traits like.isButtonand.isSelectedon the wrapper. - Map every custom gesture to
accessibilityAdjustableActionor a named accessibility action, and keepaccessibilityValueupdated on the main actor. - Align reading order with
accessibilitySortPriority(_:)and hide decoration usingaccessibilityHidden(true). - Keep
accessibilityActivationPoint(_:)inside bounds for small or irregular targets. - Add stable
accessibilityIdentifiers and XCUITests for labels, values, and hit-ability. - Validate with Accessibility Inspector hit-testing and focus tracing before merge.
- Post
UIAccessibilityannouncements for meaningful async outcomes; centralize and throttle to avoid duplicates. - Profile on-device with Time Profiler; avoid sharing observable models across distant ancestors that cause redundant invalidations.
Closing Takeaway
Start by modeling each custom control as a single accessible element with explicit labels, values, and traits. Map intent to standard actions so VoiceOver users can operate the UI without bespoke gestures. Keep focus order aligned with visual layout, hide decoration aggressively, and post announcements only when outcomes matter. Back these choices with tests, device profiling, and measured rollout. Once the fundamentals hold, add rotors and advanced behaviors where they clearly accelerate real tasks.
Swift/SwiftUI Code Example
import SwiftUI
struct VOCombinedControl: View {
@State private var isOn = false
@State private var count = 3
var body: some View {
Button {
isOn.toggle()
} label: {
HStack(spacing: 12) {
Image(systemName: isOn ? "star.fill" : "star")
.foregroundStyle(isOn ? .yellow : .primary)
Text("Favorites")
Spacer(minLength: 8)
Text("\(count)")
.monospacedDigit()
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Capsule().fill(.secondary.opacity(0.2)))
Image(systemName: "chevron.right")
.font(.footnote)
.foregroundStyle(.secondary)
}
.padding(.vertical, 8)
}
.buttonStyle(.plain)
.accessibilityElement(children: .ignore)
.accessibilityLabel("Favorites")
.accessibilityValue(isOn ? "On, \(count) items" : "Off, \(count) items")
.accessibilityHint("Double-tap to toggle. Swipe up or down for more actions.")
.accessibilityAddTraits(.isButton)
.accessibilityAddTraits(isOn ? .isSelected : [])
.accessibilityAction(named: isOn ? "Turn Off" : "Turn On") { isOn.toggle() }
.accessibilityAction(named: "Increment Count") { count += 1 }
}
}