Skeleton Noise
Status: ready
Symptom
Loading placeholders are in the accessibility tree as real content. VoiceOver reads fake rows: "Order, text", "Price, text", "button", eight times, before anything has loaded.
Where it shows up
Feeds, order history, search results, transactions, delivery tracking, support tickets: any list with a shimmer or a redacted placeholder.
Why it blocks the task
The user thinks content exists, or swipes through 8 fake rows to find out it doesn't. Switch Control scans every placeholder. When the real content arrives, the element under VoiceOver focus disappears and focus jumps to the top or nowhere. On a slow network this happens on every screen, every time.
Root cause
The loading UI reuses the real cell, and nothing tells assistive technology that it's a placeholder.
- The skeleton is built from production cells with placeholder data.
.redacted(reason: .placeholder)changes only the look. The text is still in the tree.- Loading, empty, error and content share one view, so they share one accessibility shape.
- A refresh replaces the list and resets focus.
Before
List(0..<8) { _ in
OrderRow(order: .placeholder)
.redacted(reason: .placeholder)
}
VoiceOver says:
Order 0000, text
$00.00, text
Reorder, button
Order 0000, text
...
Fix
Verdict: loading is one element that says it's loading. The skeleton is hidden. Each state (loading, empty, error, content) is a separate branch in code.
Why: the user hears the real state once, and the list they enter later is real.
SwiftUI:
switch state {
case .loading:
ZStack {
SkeletonOrdersList()
.redacted(reason: .placeholder)
.accessibilityHidden(true)
ProgressView("Loading orders")
}
case .empty:
ContentUnavailableView("No orders yet", systemImage: "bag")
case .failed:
VStack {
Text("Orders didn't load.")
Button("Try again") { reload() }
}
case .loaded(let orders):
OrdersList(orders: orders)
}
UIKit:
skeletonView.accessibilityElementsHidden = true
loadingIndicator.accessibilityLabel = "Loading orders"
For loading longer than a few seconds, show progress as a value (accessibilityValue = "60 percent" with .updatesFrequently), not as announcements.
When content arrives on a screen the user just opened, focus doesn't need to move: VoiceOver continues on the first element. On a pull-to-refresh, keep the scroll position and the focused row.
What we don't do
- We don't announce "Loading" for a load shorter than about 1 second. The user doesn't notice it, and the announcement interrupts.
- We don't move focus to the first row when content loads in the background.
Check
Profile: VoiceOver, Network Link Conditioner set to 3G
Task: open order history
Expected: one "Loading orders" element, no placeholder rows
when orders load, focus doesn't jump
empty, error and loading sound different
Profile: Switch Control
Task: scan while loading
Expected: no scan stops on placeholders
Back is reachable
WCAG mapping
4.1.3 Status Messages: the loading status is available.1.3.1 Info and Relationships: placeholders aren't exposed as data.2.4.3 Focus Order: focus stays meaningful when content replaces loading.
App Store and product risk
A claim of VoiceOver support is tested on a slow network as often as on a fast one. Skeleton noise turns every load into a wall of fake rows.