Refine accessibility for custom controls
Make SwiftUI custom controls work with VoiceOver and other assistive technologies using labels, values, adjustable actions, passthrough gestures, custom actions, and Direct Touch.
TL;DR
- Use the visual affordances of a control as an accessibility checklist: purpose, value, available actions, and feedback.
- For custom slider-like controls, expose a label and value, add the adjustable trait, implement accessibility adjustable actions, and announce meaningful value changes.
- Use the VoiceOver passthrough gesture plus a tuned accessibility activation point for fine-grained direct manipulation of a focused control.
- For complex controls, prefer custom actions for multi-axis or repeated operations, and use Direct Touch for gesture-heavy regions while still offering alternative actions when possible.
Accessibility model for custom controls
Custom controls often communicate through visual cues: shape, position, handles, axes, gestures, and immediate visual feedback. Assistive technologies do not automatically receive all of that implicit information, so custom controls need explicit accessibility metadata and interactions.
The session frames custom-control accessibility around four questions: can someone understand the control's purpose, read its current value, know what actions are available, and receive feedback when interaction changes state?
- Use an accessibility label to name the purpose of the control.
- Expose an accessibility value when the control represents state or a measurement.
- Add traits and actions that match the interaction model, such as adjustable behavior for a one-dimensional value.
- Provide feedback through value updates, announcements, sound, haptics, or other output that assistive technologies can perceive.
Make a custom slider behave like a standard adjustable control
The coffee dispenser example starts as a custom vertical fill control that visually indicates ounces of coffee and responds to dragging. VoiceOver initially cannot describe the control or adjust it in the same way as a standard SwiftUI slider.
The fix is to mark the view as an accessibility element, provide a label and value, add the adjustable trait, and implement increment and decrement behavior with an accessibility adjustable action. This gives VoiceOver users the familiar swipe-up/swipe-down adjustment model.
- Use
.accessibilityElement()when the custom drawing should be treated as one accessible control. - Use
.accessibilityLabelfor the control name and.accessibilityValuefor the current state. - Use
.accessibilityAddTraits(.adjustable)and.accessibilityAdjustableActionfor slider-like controls. - Handle both
.incrementand.decrementin the adjustable action closure.
Expose a custom coffee dispenser as an adjustable control
Adds purpose, value, the adjustable trait, and VoiceOver increment/decrement behavior to a custom slider-like control.
import SwiftUI
struct CoffeeDispenserView: View {
@State var coffee: Double = 0.0
var body: some View {
CoffeeSlider(value: coffee)
.accessibilityElement()
.accessibilityLabel("Coffee Dispenser")
.accessibilityValue("\(Int(coffee)) ounces")
.accessibilityAddTraits(.adjustable)
.accessibilityAdjustableAction { direction in
switch direction {
case .increment:
increaseCoffeeAmount()
case .decrement:
decreaseCoffeeAmount()
}
}
}
}Support precise manipulation with passthrough gestures
VoiceOver's passthrough gesture lets someone double-tap and hold, then send touch events directly to the focused control. This is useful when the adjustable action is too coarse, such as changing the coffee amount by fractions of an ounce.
The passthrough gesture begins at the control's accessibility activation point. For a vertical fill control, setting that point to the current fill level makes the gesture begin where the visible handle or value is, leaving useful room to move in either direction.
During passthrough interaction, avoid announcing every tiny value change. The example tracks the last spoken value and throttles announcements so feedback stays meaningful rather than noisy.
- Use
.accessibilityActivationPointto align direct manipulation with the control's current visual state. - Post accessibility announcements when the value changes enough to matter.
- Throttle announcements; the example waits at least about 0.3 seconds and skips unchanged values.
Place the activation point at the current fill level
Starts VoiceOver passthrough interaction at the current coffee level instead of the default center point.
import SwiftUI
struct CoffeeDispenserView: View {
@State var coffee: Double = 0.0
var body: some View {
CoffeeSlider(value: coffee)
.accessibilityActivationPoint(
UnitPoint(x: 0.5, y: 1 - coffee)
)
}
}Announce meaningful value changes
Posts accessibility announcements during direct manipulation while avoiding excessive speech.
import SwiftUI
struct CoffeeDispenserView: View {
@State var coffee: Double = 0.0
var body: some View {
CoffeeSlider(value: coffee)
// ...
.onChange(of: coffee) { _, newValue in
if sufficientTimeSinceLastAnnouncement() && valueHasChanged() {
cacheLastSpokenValue(newValue)
AccessibilityNotification
.Announcement(newValue)
.post()
}
}
}
}Use custom actions for multi-dimensional controls
An equalizer pad has two axes: frequency and amplitude. The adjustable trait only models a single increment/decrement axis, so it is not the best fit for navigating a two-dimensional surface.
Custom actions expose operations that assistive-technology users can discover, select, and activate. In the equalizer example, four actions move the handle up, right, down, and left by fixed steps, clamped to the chart bounds.
- Use custom actions when a control has more than one meaningful operation or axis.
- Give each action a descriptive label such as "Move Up" or "Move Right."
- Update one dimension at a time and provide state feedback through the accessibility value or the control's own output.
- Custom actions are also useful as alternatives for users who cannot perform complex gestures.
Add directional custom actions to a two-axis control
Exposes a two-dimensional pad through familiar, discrete VoiceOver actions instead of forcing it into a single adjustable axis.
import SwiftUI
struct EqualizerView: View {
var body: some View {
EqualizerPad()
.accessibilityActions("Move Up") {
increaseY(by: 10)
}
.accessibilityActions("Move Right") {
increaseX(by: 10)
}
.accessibilityActions("Move Down") {
decreaseY(by: 10)
}
.accessibilityActions("Move Left") {
decreaseX(by: 10)
}
}
}Use Direct Touch for gesture-heavy interactive regions
Some controls are defined by repeated or varied gestures, such as an interactive virtual cat that responds to patting, tapping, and pinching. A one-time passthrough gesture may not be the best model because users may want to perform several gestures in sequence.
The Direct Touch API marks a region where touch events pass to the control instead of being interpreted by VoiceOver. The .requiresActivation option prevents accidental interaction while exploring: the user must activate the region before direct touch begins, and it remains active until focus moves elsewhere.
The session cautions that not everyone can perform direct touch gestures. When possible, provide custom actions or other accessible alternatives in addition to Direct Touch.
- Use
.accessibilityDirectTouch([.requiresActivation])for regions that need direct gesture input after explicit activation. - Consider
.silentOnTouchfor controls that provide their own audio feedback and should not be interrupted by VoiceOver speech. - Still expose labels, values, and alternative actions where possible.
- Direct Touch can help VoiceOver users access the same gesture vocabulary as sighted users, but it should not be the only interaction path when alternatives are feasible.
Enable Direct Touch for an interactive surface
Names the interactive region, reports the cat's current reaction, and allows direct gesture input after activation.
import SwiftUI
struct VirtualCat: View {
var cat: CatModel
var body: some View {
InteractiveCatSurface()
.accessibilityLabel("Virtual Cat")
.accessibilityValue(cat.currentReaction.description)
.accessibilityDirectTouch([.requiresActivation])
}
}Resources
Related Sessions
Enhance the accessibility of your reading app
Build accessible long-form reading experiences with VoiceOver, Speak Screen, text navigation, page turning, selection, and UITextInput.
Prepare your tvOS apps for Dynamic Type
Learn how to support tvOS 27 Large Text by replacing fixed typography and constraints with Dynamic Type styles and adaptive layouts.