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:
- What triggered it?
- Is it a new screen, a modal or a change within the screen?
- Where does focus go: the title, the error, the first result?
- 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
- Moving focus on every small update.
- Moving focus away from a field the user is typing in.
- Focusing a toast that disappears.
- Focusing a disabled button when the fix is in a field above.
- Using
.announcementwhen the user needs a place to fix something.
WCAG mapping
2.4.3 Focus Order: focus follows the task.3.3.1 Error Identification: the error can be found.3.3.3 Error Suggestion: the user gets a fix.