02UI

Loading and skeleton

A skeleton mimics the shape of content that hasn't arrived yet. A spinner just says something is happening. Which one to reach for comes down to how long the wait runs and whether you already know the shape of what's coming.

Feedback and stateBuild difficulty: Medium<div role="status" aria-live="polite">

Also called Skeleton screen, Loading state, Placeholder content, Shimmer.

Every request has a gap between being asked and being answered, and something has to fill it. Get that part wrong and a fast product feels slow. The choice isn't really about taste. It's about whether you know, ahead of time, what shape the answer will take.

Anatomy of a skeleton

Anatomy of a skeleton: the shape standing in for real content, varied line widths, the pulse animation, and the reserved space it holds.
  1. 1Shape. Stands in for the part of the real content it's replacing: a circle for an avatar, a bar for a line of text, a rectangle for an image.
  2. 2Line width. Varies the way real sentences do. A row of identical full-width bars is the fastest way to make a skeleton read as fake.
  3. 3Pulse. The animation loop that signals motion. A skeleton that never moves reads as broken rather than loading.
  4. 4Reserved space. Sized to the exact box the real content will fill, so nothing shifts the instant it swaps in.

The shape stands in for whatever part of the real content it's replacing: a circle for an avatar, a bar for a line of text. Line widths vary the way real sentences do, the pulse signals that something is still in motion rather than stuck, and the whole block reserves the exact space the real content will need.

Here it is working, next to the spinner it competes with.

Loading

A skeleton shaped like the row it's replacing, next to a spinner with no shape of its own.

When to use loading and skeleton

Use loading and skeleton when

  • The wait is long enough to notice, past roughly a second
  • You already know the shape of what's coming: a list, a card, a profile (reach for a skeleton)
  • The shape isn't known, or it's a single control rather than a layout (reach for a spinner instead)
  • The space needs to stay reserved so nothing jumps once the real content lands

Reach for something else when

  • The response usually finishes in well under a second. A loading state here only flashes and disappears, which reads as a glitch.
  • There's real, calculable progress to report, like a file upload's percentage. Use a progress bar instead.
  • The result came back empty. That's the actual outcome, not a wait. Use an empty state instead.
  • The condition is ongoing rather than a one-time wait, like an account under review. Use a banner instead.

Variants

Refreshing row

List rows, a card, a plain spinner, and the same spinner sized down for an inline refresh.

Skeleton, list shape. Rows of avatar-plus-two-lines, repeated. Fits a feed, an inbox, a table about to populate.

Skeleton, card shape. A wider block for an image plus a couple of text lines beneath it. Fits a grid of cards or a single profile.

Spinner. A rotating indicator with no shape of its own. Fits a button mid-submit, or any wait too short or too unpredictable to model.

Inline spinner. The same indicator, sized down and placed beside the specific piece of content it's replacing, like one row refreshing inside an otherwise loaded table.

Progress bar (named, not built here). Determinate, with a real percentage. Worth knowing as the third option even though it's a separate component from either of these.

States

Idle
Q3 marketing site
Pending, under threshold
(nothing rendered)
Pending, skeleton
Pending, spinner
Resolved
Q3 marketing site
Failed
Couldn't load
StateWhat changesWatch out for
IdleReal content, nothing loadingThe baseline everything else compares against
Pending, under thresholdNothing renders yetThis is deliberate. Showing a loading state here just adds a flash the person didn't need
Pending, skeletonShape and pulse render in place of contentMatch the layout closely enough that the swap doesn't shift anything
Pending, spinnerRotating indicator, usually centred or inlineNever trap focus or block input just because something is loading
ResolvedReal content replaces the placeholderThe swap itself shouldn't cause a layout jump if the placeholder was sized right
FailedAn error message replaces the placeholder, not a stalled spinnerA spinner that never resolves into anything is indistinguishable from one that's broken

How a loading state behaves

It appears after a short delay, not instantly. Roughly 200 to 300ms is common practice, so a response that comes back fast never shows anything at all.

It never traps focus or blocks the rest of the page, unless the action genuinely can't be interrupted, like a payment mid-submit. Even then, say so, rather than leaving someone to guess whether the interface has frozen.

It resolves into content or an error, never nothing. A spinner or skeleton that just stays on screen indefinitely is the same experience as the interface actually being broken.

It reserves the space the real content will use. This is what a skeleton is for that a spinner isn't: preventing everything below it from jumping the instant the data lands.

Loading and skeleton accessibility

Keyboard

KeyWhat happens
None specificNeither a skeleton nor a spinner is interactive, so neither is a tab stop. Focus should already be somewhere sensible, usually wherever it was before the request started.

The rest

  • Announce it once, not on every re-render. aria-live="polite" on the container, updated only when the state actually changes from loading to resolved, not on every frame of an animation.
  • aria-busy="true" on the region that's loading, cleared the moment real content replaces it.
  • Respect reduced motion. Both the skeleton's pulse and the spinner's rotation should slow or stop for prefers-reduced-motion, rather than running at full speed regardless.
  • Contrast still applies to the placeholder itself, even though it holds no text. A skeleton block needs enough contrast against its background to read as present (WCAG 1.4.11 for non-text UI components).
  • Never colour alone for a failed load. Pair a colour change with an icon or a word, the same rule that applies to every other status in this library (WCAG 1.4.1).

What a loading state should say

Usually nothing. A skeleton or spinner is understood without a caption in almost every context. Adding "Loading..." next to a shape that's already communicating that is the information twice.

When it does speak, be specific. "Loading your projects" beats a bare "Loading" if the wait runs long enough that the label is worth reading at all.

Never promise a time you can't guarantee. "This may take a moment" is honest. "Almost done" attached to a spinner that's been spinning for 30 seconds erodes trust faster than saying nothing would have.

Common loading and skeleton mistakes

Do

Matches the row it's replacing: an avatar, a name, a shorter line underneath. The swap won't move anything.

Don't

Smaller and narrower than the real row that lands a moment later, so the layout jumps anyway once it swaps in.

The one that shows up constantly is a skeleton shaped nothing like what actually loads, so the swap jolts the layout exactly as hard as no placeholder would have.

1. A skeleton whose proportions don't match the real content. The whole point was preventing a layout jump, and a mismatched shape causes one anyway.

2. Showing a loading state for a response that finishes in 100ms. All that renders is a flash, which reads as a flicker rather than feedback.

3. A spinner that never resolves. Nothing distinguishes "still working" from "silently broken" past the point someone starts to wonder.

4. Every skeleton block the exact same width. Real text varies. A row of identical bars reads as artificial the moment someone looks twice.

5. A skeleton for content that already failed to load. At that point it's not loading anymore. Swap it for an error message, not a shape that implies the wait is still in progress.

6. Blocking input for an update that didn't need to. A background refresh, like a feed pulling new items, rarely needs to freeze the screen the way a full-page load might.

How to build a skeleton

<div class="skeleton-row" role="status" aria-busy="true" aria-live="polite">
  <span class="skeleton skeleton--circle"></span>
  <span class="skeleton skeleton--line" style="width: 60%"></span>
  <span class="skeleton skeleton--line" style="width: 40%"></span>
  <span class="sr-only">Loading</span>
</div>

The visible shapes carry no text, so the sr-only label is what a screen reader actually announces. Drop the role and aria-busy once real content replaces the skeleton, rather than leaving stale loading semantics on a finished element.

Edge cases

The wait outlasts 10 seconds. Past Nielsen's third limit, add a real message, and a progress indicator if there's any way to calculate one, rather than leaving a bare spinner running.

A request fails after a skeleton was already showing. Swap directly to the error state. Don't let the skeleton sit there implying the wait is still active.

Someone navigates away mid-load. Cancel the pending request if the API supports it, so a stale response doesn't land on a screen that's no longer showing it.

A partial response arrives. Resolve each section independently rather than holding the whole skeleton until every piece is back, so a slow section doesn't hold up the ones that already finished.

Reduced motion. Both the pulse and the spin need a static or heavily slowed fallback for anyone with the setting on.

Loading and skeletons in real products

LinkedIn popularised the skeleton screen at scale, shaping placeholders to the exact feed-card layout that follows, which is part of why the pattern is sometimes still called a "LinkedIn loader."

YouTube shows thumbnail-shaped skeleton blocks while a page of videos loads, then swaps each one in as its own thumbnail resolves rather than waiting for the whole grid.

Stripe's dashboard uses a small inline spinner inside a button during submission and reserves a full skeleton layout only for pages loading a genuinely unknown amount of data, like a transactions table.

Common questions

Skeleton or spinner?
Ask whether you know the shape of what's coming. A profile page, a table, a feed all render the same layout every time, so a skeleton can mimic it. A single button doing a one-off action, or a result whose shape can't be predicted, gets a spinner instead.
How long before something should show a loading state at all?
Jakob Nielsen's three response-time limits from 1993 still hold: 0.1 second feels instant, 1 second keeps someone's flow going even though they notice the wait, and 10 seconds is roughly the limit before attention drifts elsewhere. A common practice is delaying the loading UI by 200 to 300ms, so a response that finishes fast never flashes one at all.
Should a skeleton match the real content's colours?
No. Keep it a flat, muted tone, usually the same one across the whole product. Guessing at colour before the real data arrives risks a flash of the wrong colour, which reads worse than a neutral placeholder ever would.
Does a skeleton need to match the real layout exactly?
Close is enough. What matters is the overall shape and the space it reserves. A skeleton that's noticeably narrower or shorter than the content it's standing in for causes the exact layout jump it exists to prevent.
What replaces a skeleton for something like a file upload?
A determinate progress bar, since there's a real percentage to show. Neither a spinner nor a skeleton can represent a number, and forcing one to try just hides information you already have.

Last reviewed 30 JUL 2026