Focus Recovery

Status: ready

Intent

After an error, a sheet, an update or a result, focus goes where the user continues the task.

Problem

The UI changes and focus doesn't. It stays on the button that was just pressed, behind a sheet, on a skeleton that's gone. A sighted user sees the new state. A VoiceOver user hears the old one and has to search.

Solution

Verdict: every change of context has a written focus target. Decide it in design, not after QA finds it.

Why: a recovery that starts in the right place takes 1 swipe. A recovery that starts from the top of a form takes 10.

For each change, answer four questions:

  1. What triggered it?
  2. Is it a new screen, a modal or a change within the screen?
  3. Where does focus go: the title, the error, the first result?
  4. Where does focus return when it's closed?
Change Focus target
Form submit failed, one field The error text next to the field
Form submit failed, several fields An error summary at the top
Sheet or modal opened Its title (native .sheet does this)
Sheet closed The control that opened it
Loading finished on a screen the user just opened Nothing, VoiceOver continues on its own
Task result (added to cart) Nothing, an announcement

Don't move focus for small state changes, decorative animation, a live counter, or while the user is typing.

Implementation

SwiftUI, error after submit:

@State private var expirationError: String?
@AccessibilityFocusState private var errorFocused: Bool

TextField("Expiration date", text: $expiration, prompt: Text("MM/YY"))
    .accessibilityLabel("Expiration date")
    .accessibilityValue(expirationError.map { "\($0). \(expiration)" } ?? expiration)

if let expirationError {
    Text(expirationError)
        .accessibilityFocused($errorFocused)
}

Button("Pay") {
    expirationError = validate(expiration)
    errorFocused = expirationError != nil
}

VoiceOver after Pay:

Card expired. Enter a date after 11/26.

SwiftUI, several errors: the same with a summary Text at the top of the form as the focus target.

UIKit:

// change within the screen
UIAccessibility.post(notification: .layoutChanged, argument: errorSummaryLabel)

// new screen or custom modal
UIAccessibility.post(notification: .screenChanged, argument: sheetTitleLabel)

// result only, focus stays
UIAccessibility.post(notification: .announcement, argument: "Card added")

An announcement doesn't replace a focus target. "Payment failed" is spoken and gone, and the user is still on Pay.

Check

Profile:   VoiceOver
Task:      submit the payment form with an expired card
Expected:  focus is on the error right after Pay, without swiping
           the error is spoken once
           one swipe reaches the field, which still has its value
Profile:   VoiceOver
Task:      open the address sheet, close it
Expected:  focus starts on the sheet title
           after close, focus is on the button that opened it
Profile:   Switch Control, Full Keyboard Access
Task:      the same error
Expected:  the next scan or Tab starts at the error, not at the top

In an XCTest with XCUIVoiceOverService (iOS 27+), read currentSpeech() right after the action. Swiping to the error first proves it's reachable, not that focus moved. Chapter 13, Decision 2.

Pitfalls

  1. Moving focus on every small update.
  2. Moving focus away from a field the user is typing in.
  3. Focusing a toast that disappears.
  4. Focusing a disabled button when the fix is in a field above.
  5. Using .announcement when the user needs a place to fix something.

WCAG mapping

  1. 2.4.3 Focus Order: focus follows the task.
  2. 3.3.1 Error Identification: the error can be found.
  3. 3.3.3 Error Suggestion: the user gets a fix.
  1. Silent Error
  2. Focus Black Hole
  3. Animation Only Feedback
  4. Disabled Mystery
  1. Chapter 4
  2. Chapter 10
  3. Chapter 11
  4. Chapter 13
  1. VoiceOver gestures guide

Sources

  1. Apple Developer: accessibilityFocused
  2. Apple Developer: AccessibilityNotification.LayoutChanged
  3. Apple Developer: UIAccessibility.Notification