Loading State Contract
Status: ready
Intent
A loading state says that the app is waiting, how far along it is and what the user can still do. It doesn't pretend to be content.
Problem
Skeleton rows are read as real rows. A spinner says nothing. Progress updates interrupt speech. A refresh replaces the list and throws focus away.
Solution
Verdict: each kind of loading has one defined behavior.
| Loading | Behavior |
|---|---|
| Under about 1 second, on navigation | Nothing extra |
| Blocking the screen | One element: "Loading orders" |
| Long, with progress | Label + value, .updatesFrequently, announce completion |
| Decorative skeleton | Hidden from accessibility |
| Refresh of existing content | Keep scroll position and focus |
| Failed | Focus the error with a Retry button |
Why: the user hears the real state once, never reads a placeholder, and doesn't lose their place when data arrives.
Implementation
SwiftUI, one branch per state:
switch state {
case .loading:
ZStack {
SkeletonOrdersList()
.redacted(reason: .placeholder)
.accessibilityHidden(true)
ProgressView("Loading orders")
}
case .loaded(let orders):
OrdersList(orders: orders)
case .empty:
ContentUnavailableView("No orders yet", systemImage: "bag")
case .failed(let message):
VStack {
Text(message)
Button("Try again") { reload() }
}
}
The enum isn't the point. The point is that loading, empty, error and content don't share one accessibility shape.
UIKit:
skeletonContainer.accessibilityElementsHidden = true
loadingIndicator.accessibilityLabel = "Loading transactions"
Progress:
ProgressView("Uploading receipt", value: progress)
// on completion
AccessibilityNotification.Announcement("Receipt uploaded").post()
A refresh keeps the user's place. In a List with stable ids, SwiftUI keeps the focused row as long as it still exists. If the data source replaces every identity on reload, focus is lost, so keep IDs stable.
Check
Profile: VoiceOver, Network Link Conditioner on 3G
Task: open order history
Expected: one loading element, no placeholder rows
Back is reachable
content loads without focus jumping
Profile: VoiceOver
Task: pull to refresh while focused on the third order
Expected: focus stays on the third order
Profile: Reduce Motion
Task: the loading screen
Expected: the state is clear without the shimmer
Pitfalls
- Announcing "Loading" repeatedly.
- Moving focus on every refresh.
- A skeleton that hides the real Back, Retry or Cancel.
- Empty and loading that sound the same.
- New IDs for every item on every reload.
WCAG mapping
4.1.3 Status Messages: loading and completion are available.1.3.1 Info and Relationships: placeholders aren't exposed as data.2.4.3 Focus Order: refresh keeps navigation predictable.