The problem
A page that has nothing to show yet has three honest ways to say so: promise a shape (“here’s
roughly what’s coming”), promise nothing but activity (“something is happening”), or promise a
measurable amount (“this much is done”). Using the wrong one either misleads the reader about
what’s arriving or wastes the one advantage the right one had.
Get the duration wrong and the failure is different but just as visible. A loading state that
flashes for 150ms and vanishes reads as a glitch. One that never resolves reads as a hang, and
the reader has no way to tell a slow network from a broken one.
Titan already ships all three primitives — Skeleton, Spinner, ProgressBar — with no decision
about which to reach for when, and evidence that the gap has a real cost: three separate
applications have independently built what appears to be the same TabLoadingSkeleton, at 21
instances each. That is not three teams making different choices. It is three teams answering
the same unanswered question on their own.
Decide first: what do you actually know?
Skeleton is not the default just because it is the most-used. It is correct only when the
shape is known. Reaching for it when the shape is a guess trades a blank region for a
confidently wrong one.
Vocabulary
When this applies
- Content whose eventual layout — line count, widths, roughly how many rows or fields — is
known before it arrives.
- A wait long enough to notice, and a region large enough that a blank space would read as
broken rather than loading.
When it doesn’t
Structure
Behavior
Constraints
Content
Accessibility
A skeleton is decorative; the region it sits in is not. Hide skeleton shapes from assistive
technology and put aria-busy="true" on the region that’s loading. Announce once, when the
content resolves — not the fact that loading started, and not on every render while it’s still
in progress. A screen-reader user given a running commentary of “loading” has been told less
than one given a single, timed “done.”
Reduced motion is currently unhandled at the component level. Skeleton’s pulse and wave
animations do not respond to prefers-reduced-motion: reduce — the same absence recorded at the
motion-foundation level generally, where seven durations exist and none has a reduced variant
(see Known issues). Until the component handles this itself, pass
animation={false} at every call site under that preference.
A spinner or progress bar needs an explicit accessible name at the call site. Neither sets
one automatically — pair a Spinner with aria-describedby on the region it describes and
aria-busy on that region; do the same for a ProgressBar reporting a specific process’s state.
Variations
Anti-patterns
The flashing skeleton. Showing a placeholder for a wait that resolves in 150ms turns a fast
response into a visible flicker. The fix is not a faster skeleton — it’s not showing one at all
under the ~300ms threshold.
The eternal skeleton. Nothing about Skeleton times out on its own. Without a timeout in the
calling code, a failed or hung request looks identical to “still loading,” and the reader waits
far longer than they should before concluding otherwise.
Two animations in one screen. One region pulsing while another waves reads as two systems
loading independently, not one page. Titan ships both animations with no decision between them —
pick one and stay consistent within a screen.
Rebuilding the skeleton screen per application. The pattern this page exists to prevent has
already happened three times over — see Known issues. A composed skeleton
screen worth naming is worth sharing.
Known issues
Gaps in the current rules
- Where the exact line sits between “well under a second” (show nothing) and “noticeable” (show
an indicator) — this page names the shape of the threshold without a specific number beyond
the ~300ms already stated in Constraints.
- What a ProgressBar should do when the underlying measurement stalls — hold its last known
value, or show some “no progress” signal distinct from a hang.
- Whether a skeleton screen, once built once and shared, should live as a Titan export or stay
an application-level composition — the constraint says “shared,” not where.
Why it works this way
The decision runs on what’s actually known, not on which primitive looks best, because each of
the three makes a different claim and a wrong claim costs the reader something specific. A
skeleton claims to know the shape; get that wrong and the page visibly jumps on load, which is
worse than the blank moment a spinner would have shown honestly instead.
The threshold exists because an indicator’s failure mode below that point isn’t
“unnecessary” — it’s actively worse than nothing. A shape that appears and vanishes before a
reader can register what it was reads as a flicker, not as helpful feedback withheld; the fix is
omission, not a faster or smaller version of the same indicator.
Skeleton,
Spinner, and
ProgressBar, the three primitives this
pattern chooses between. Error handling, the
handoff once a wait exceeds its timeout. Empty & zero states,
what a load resolves to when it succeeds with nothing to show.
Status
First-pass proposal, not established policy. No audited Invoca screen backs this page —
Titan’s design library was not available while writing it. It is built from general
interaction-design practice and from what Skeleton,
Spinner, and
ProgressBar already establish.
It also sits on top of a real, verified finding, not a hypothetical one:
TITAN-GAP-31. Skeleton — the
component this page leans on most — carries no theme overrides, no tokens, and no
representation in the design library at all, despite being the most-used component in this
area: 132 usages across 13 applications. This page proposes how to use Skeleton
consistently. It does not, and cannot, resolve what Skeleton is — that gap stays open, and
everything below is a usage layer built on top of an undecided foundation.