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

  1. accessibilityIdentifier isn't a label. It's never read.
  2. No role words in the label ("Pay button").
  3. No Text + .onTapGesture for anything the user needs.
  4. No state in the title. Use Toggle, a value or a trait.
  5. A button isn't a substitute for Picker, Slider or Stepper.

WCAG mapping

  1. 4.1.2 Name, Role, Value: the control exposes name, role and state.
  2. 2.1.1 Keyboard: native controls work without touch.
  3. 2.5.3 Label in Name: the accessible name contains the visible text.
  1. Tiny Target
  2. Duplicate Voice Command
  3. Label Soup
  4. Mystery Icon
  5. Color Only State
  1. Chapter 3
  2. Chapter 7
  3. Chapter 10
  4. Chapter 11

Sources

  1. Apple Human Interface Guidelines: Accessibility
  2. Apple Developer: Button
  3. Apple Developer: UIAccessibility
  4. Apple Developer: UIAccessibilityTraits.toggleButton