Semantic Control
Status: ready
Intent
Anything the user can activate is a real control with a name, a role, a state and an activation. If the platform has the control (Button, Toggle, Slider, Stepper, Picker, TextField, or the UIKit equivalent), start there.
Problem
A custom tap target works for a finger. The accessibility tree doesn't know what it is. VoiceOver reads the text without "button". Switch Control may not stop on it. Voice Control has no command for it. XCTest finds a view instead of a button.
Solution
Verdict: native control first. A custom view gets name, role, state and activation by hand only when no native control fits.
Why: a native control brings the role, the state, the activation for every input method, Dynamic Type and localization of the role word. A custom view gets only what you remember to add.
Implementation
SwiftUI, a button:
// Bad: no role, Switch Control and Full Keyboard Access may skip it
Text("Pay").padding().onTapGesture { pay() }
// Good
Button("Pay") { pay() }
VoiceOver says "Pay, button". The role comes from the control. The label doesn't say "button".
SwiftUI, on/off state:
// Bad: looks like a switch, reads as text
HStack {
Text("Marketing emails")
Image(isOn ? "switch-on" : "switch-off")
}
.onTapGesture { isOn.toggle() }
// Good
Toggle("Marketing emails", isOn: $isOn)
A button that turns something on and off is also a toggle, even if it looks like a button. Don't put the state in the title ("Favorite" / "Favorited"):
Toggle(isOn: $isFavorite) {
Label("Favorite", systemImage: isFavorite ? "heart.fill" : "heart")
}
.toggleStyle(.button)
The name stays "Favorite", the state is exposed by the toggle.
UIKit, a button:
// Bad
let payView = UIView()
payView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(pay)))
// Good
let payButton = UIButton(configuration: .filled())
payButton.setTitle("Pay", for: .normal)
payButton.addTarget(self, action: #selector(pay), for: .touchUpInside)
UIKit, when a custom view is unavoidable:
payView.isAccessibilityElement = true
payView.accessibilityLabel = "Pay"
payView.accessibilityTraits = .button
// and override accessibilityActivate() if the tap target isn't the center of the view
For a custom on/off row: .toggleButton trait (iOS 17+) and a value. Chapter 11, Decision 2.
Check
Profile: VoiceOver
Task: activate every primary action on the screen
Expected: each control has a user-facing name
the role is spoken by the system, not in the label
on/off controls say their state
Profile: Switch Control
Task: scan and activate the primary action
Expected: the control is a scan target
activation works without a custom gesture
Profile: Full Keyboard Access (iPad)
Task: Tab to the control and press Space
Expected: it activates
Pitfalls
accessibilityIdentifierisn't a label. It's never read.- No role words in the label ("Pay button").
- No
Text+.onTapGesturefor anything the user needs. - No state in the title. Use
Toggle, a value or a trait. - A button isn't a substitute for
Picker,SliderorStepper.
WCAG mapping
4.1.2 Name, Role, Value: the control exposes name, role and state.2.1.1 Keyboard: native controls work without touch.2.5.3 Label in Name: the accessible name contains the visible text.