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

  1. Announcing "Loading" repeatedly.
  2. Moving focus on every refresh.
  3. A skeleton that hides the real Back, Retry or Cancel.
  4. Empty and loading that sound the same.
  5. New IDs for every item on every reload.

WCAG mapping

  1. 4.1.3 Status Messages: loading and completion are available.
  2. 1.3.1 Info and Relationships: placeholders aren't exposed as data.
  3. 2.4.3 Focus Order: refresh keeps navigation predictable.
  1. Skeleton Noise
  2. Announcement Spam
  3. Silent Error
  4. Focus Black Hole
  1. Chapter 4
  2. Chapter 13
  3. Chapter 15
  4. Chapter 17

Sources

  1. Apple Developer: accessibilityHidden
  2. Apple Developer: UIAccessibilityTraits updatesFrequently
  3. Apple Developer: SwiftUI AccessibilityTraits updatesFrequently