Silent Error
Status: ready
Symptom
An error appears on screen, but the user doesn't get a way to recover. VoiceOver focus stays on Pay, the field turns red, a toast disappears after 2 seconds, and the user doesn't know what to fix.
Where it shows up
Checkout, login, payment, account recovery, support requests, address forms, any form with validation.
Why it blocks the task
A sighted touch user sees the red border. A VoiceOver, Switch Control or keyboard user is still on the button they just pressed and hears nothing new. If the entered data was also cleared, the task is a dead end: the user doesn't know what failed and has to type everything again.
Root cause
The error is drawn as decoration, not as part of the task flow.
- Focus doesn't move to the error or the invalid field.
- The error text says what's wrong but not what to do.
- The field doesn't carry the error, so returning to it says nothing.
- The error lives in a toast that disappears on a timer.
- Input is cleared after the failure.
Before
Text("Invalid date")
.foregroundStyle(.red)
After tapping Pay, VoiceOver says nothing new. Focus is still on:
Pay, button
The error is in the tree, but somewhere above the current position.
Fix
Verdict: after a failed submit, focus moves to the error. The error text says what to do. The field keeps its value and carries the error. Nothing important lives only in a toast.
Why: the user lands where the correction starts and hears the field, the problem and the next step without searching the screen.
SwiftUI:
@State private var expiration = "12/19"
@State private var expirationError: String?
@AccessibilityFocusState private var errorFocused: Bool
VStack(alignment: .leading) {
TextField("Expiration date", text: $expiration, prompt: Text("MM/YY"))
.accessibilityLabel("Expiration date")
.accessibilityValue(expirationError.map { "\($0). \(expiration)" } ?? expiration)
if let expirationError {
Text(expirationError)
.foregroundStyle(.red)
.accessibilityFocused($errorFocused)
}
}
func submit() {
expirationError = validate(expiration) // "Card expired. Enter a date after 11/26."
if expirationError != nil { errorFocused = true }
}
VoiceOver after Pay:
Card expired. Enter a date after 11/26.
One swipe further, the field:
Expiration date, Card expired. Enter a date after 11/26. 12/19, text field
UIKit, error on one field:
errorLabel.text = "Card expired. Enter a date after 11/26."
errorLabel.isHidden = false
expirationField.accessibilityValue = "Card expired. 12/19"
UIAccessibility.post(notification: .layoutChanged, argument: errorLabel)
UIKit, error for the whole form: the same, with a summary label at the top as the argument. .layoutChanged is for a change within the screen. .screenChanged is for a new screen or modal.
What we don't do
- We don't clear what the user typed.
- We don't put the only copy of an error in a toast.
- We don't use
accessibilityHintfor the error. Users can turn hints off. - We don't announce the error and leave focus on Pay. The announcement is gone in 2 seconds, the user still has to find the field.
Check
Profile: VoiceOver
Task: pay with an expired card
Expected: after Pay, focus is on the error, which is read once
the error says what to enter
the field still has 12/19
swipe right reaches the field, which reads the error in its value
Profile: Switch Control
Task: fix the date and pay again
Expected: the field is reachable without scanning the whole form
the error disappears after a valid date
WCAG mapping
3.3.1 Error Identification: the error is identified in text.3.3.3 Error Suggestion: the text says how to fix it.2.4.3 Focus Order: recovery follows the task.1.3.1 Info and Relationships: the error is tied to the field.
App Store and product risk
Rarely a rejection on its own. It's a lost order: a user who can't recover from a declined card leaves. A team that claims VoiceOver support for checkout without testing the error path has no evidence for the claim.