cf.completefrontend
My progress
SYSTEM DESIGN · #18

Design a loading skeleton system

The loading placeholders of a design system: grey shapes that stand in for content while it loads. Drawing a grey box is easy; the real work is deciding when to show nothing, a skeleton, a spinner or the old content, making the shapes match the real layout so nothing jumps, giving teams a small API that stays in sync with the real components, animating many skeletons cheaply and in step, keeping them quiet for screen readers, fitting them into streaming server rendering, and making sure every skeleton ends in content, an empty state or an error.

The question

Our product teams each build their own loading states: one uses a spinner, one a grey box that is the wrong height, one shows nothing for three seconds. Design a loading skeleton system for our design system.

Tell me when a skeleton should appear and when it should not, how you stop the page from jumping when the real content arrives, what the API looks like for teams, how it animates, how it works for screen reader users, and how it fits with React Suspense and server rendering.

Real-life examples: The grey video cards on the YouTube home page, The grey post blocks in the LinkedIn feed, The shimmering placeholders in the Facebook feed, MUI Skeleton, Chakra Skeleton, Ant Design Skeleton and shadcn/ui Skeleton, React Suspense fallbacks and Next.js loading.tsx files

Requirements, and what makes loading states hard

A skeleton (also called a skeleton screen or a placeholder) is a grey, simplified drawing of the content that is about to appear: a circle where the avatar will be, a rectangle where the image will be, and a few bars where the lines of text will be. YouTube shows grey video cards before the thumbnails load. LinkedIn shows grey post blocks. Facebook adds a moving light band, called a shimmer, across its grey blocks.

The question sounds like "draw a grey box". It is really about a loading state system: one set of rules and parts that every team uses, so the whole product loads the same calm way. The grey box is ten lines of CSS. The rules around it are the design.

Questions to ask first

  • What loads, and how fast? A feed that answers in 80 ms on a fast desktop needs different rules from a report that takes 6 seconds. Ask for the typical (p50) and slow (p95) response times. Assume p50 of about 300 ms and p95 of about 2 s for our app.
  • Is the layout known before the data arrives? A profile card always has an avatar, a name and two lines. A search result list has an unknown number of items. Known layouts can get a skeleton; fully unknown ones may need something else.
  • Which rendering model? Client-only React, server rendering, or streaming server rendering with Suspense. It decides who draws the skeleton first: the server's HTML or the browser's JavaScript.
  • Which frameworks do teams use? Assume React 19 and Next.js App Router, with some older client-only pages that fetch in effects.
  • Who are the users? Everyone: mouse, touch, keyboard, screen readers, people who turned on reduced motion, dark mode, slow Android phones on 4G.
  • Is this a new system or a cleanup? Here it is a cleanup: teams already have five kinds of loading state, so adoption and a small API matter.

Functional requirements

  • Primitives for the three shapes all content is made of: lines of text, circles, and rectangles (including media with a fixed aspect ratio).
  • Per-component skeletons, like a PostCard skeleton, built from those primitives.
  • Timing rules: show nothing for fast responses, a skeleton for medium ones, and never flicker.
  • A way to keep the old content on screen during updates, instead of replacing it with a skeleton.
  • Every loading state ends: in content, an empty state, or an error with a retry button.
  • Works with React Suspense, streaming server rendering, and client-side fetching.

Non-functional requirements, with numbers

RequirementTargetWhy
Layout stabilityCumulative Layout Shift (CLS) at or below 0.1 for 75% of page loadsThat is Google's "good" threshold. A skeleton of the wrong height is a top cause of shifts.
No flickerA skeleton is either never shown or shown for at least about 300 ms (our estimate)A grey flash of 40 ms reads as a glitch, not as loading.
Cheap animationShimmer runs on the compositor; 50 skeletons on screen cost no main-thread work per frameLoading is exactly when the main thread is busiest.
AccessibilityOne "Loading" announcement per region, none per shape; no focusable placeholdersA screen reader must not hear 30 empty boxes.
Reduced motionNo moving shimmer when the user asks for reduced motionSome users get dizzy or distracted by motion.
SizeUnder about 2 KB of CSS and JS for the primitives (our estimate)Skeletons ship in the first paint, so they must be tiny.

Out of scope, and where it is covered

  • Loading more items at the end of a list: see the infinite-scroll guide. We only cover the first load of a region.
  • The feed itself (ranking, pagination, live updates): see news-feed. The video page with its player and comments: see youtube. We only design their loading states.
  • Fixing CLS and LCP across a whole site: see web-performance-overhaul. We only make sure skeletons never cause shifts.
  • Rolling a component out to many teams: see design-system-rollout.
  • Status messages like "Saved" or "Upload failed": see toast-notifications.
  • Progress of long tasks like an upload with a percentage: that is a progress bar, which we mention but do not design here.

What makes loading states hard

A skeleton is a promise. It tells the user "something shaped like this is coming, right here, soon". Every hard part of this problem is about keeping that promise:

  • The wait time is unknown. The same request takes 40 ms on office Wi-Fi and 3 s on a train. A rule that looks right for one looks wrong for the other: a skeleton that flashes for 40 ms is noise, and a blank screen for 3 s looks broken.
  • The shape is a guess. The skeleton is drawn before the data exists. If the real post has five lines of text and the skeleton had two, everything below it jumps when it arrives. That jump is a layout shift, and it is measured by Core Web Vitals.
  • It lives next to the real component, but is written separately. When a team adds a "sponsored" badge to the card and forgets the skeleton, the two drift apart, and the shift comes back months later.
  • Many of them run at once. A feed page can show 12 cards with 5 shapes each: 60 animated shapes while the main thread is busy parsing JavaScript and rendering. A careless shimmer makes the load slower.
  • It is invisible to some users and loud to others. For a screen reader user, 60 grey boxes are 60 meaningless items, unless they are hidden. For a user who asked for reduced motion, a moving shimmer is exactly what they asked to avoid.
  • It must end. A skeleton that waits for a request that failed is worse than an error message: the user waits for nothing. Every loading state needs an exit to content, empty, or error.
  • It crosses the rendering model. With streaming server rendering, the skeleton is in the first HTML the server sends, before any JavaScript runs. With client rendering, it appears after JavaScript runs. Timing rules have to work in both.

So the design has five parts, and the chapters follow them: timing (when to show what), fidelity (shapes that match), API (primitives and composition), animation (cheap, in sync, respectful of motion settings) and integration (accessibility, Suspense and streaming, errors).

Wireframe of a feed page while it loads. The app shell header at the top is real and renders at once. Below it, a dashed loading region holds one skeleton card: a circle for the avatar with two short bars for the name and time, three text bars of falling width, and a wide rectangle with a 16 by 9 ratio for the media. Labels on the right name each part: shell with no skeleton, Circle plus 2 text lines, Text with 3 lines, Rect with ratio 16 / 9, and the region, which carries aria-busy and a hidden role status message.app.example/feedshell: no skeletonCircle + 2 linesText, 3 linesRect, ratio 16 / 9region: aria-busy+ hidden role=status
Only the region that waits for data is grey; the shell never is. Every shape maps to one primitive, and the region, not the shapes, carries the accessibility attributes.

Check your understanding · Exercise 1

Which answers to your scope questions would change the design the most? Select all that apply.

Timing: when to show nothing, and when to show a skeleton

Start with the naive rule most teams write: if (isLoading) return Skeleton. The skeleton appears the moment a request starts and disappears the moment it ends.

On a slow phone this looks fine. On a fast desktop, where the request takes 50 ms, the user sees a grey frame for 50 ms and then the content: a flash of skeleton. Three frames of grey look like a glitch, and the page feels slower than if nothing had changed at all.

Jakob Nielsen's response time limits (1993, still cited by Nielsen Norman Group, or NN/g): about 0.1 s feels instant and needs no feedback; about 1 s keeps the flow of thought, though users notice the delay; about 10 s is the limit of attention, and longer waits need a progress indicator with an estimate. NN/g's skeleton screen article draws the same line: for loads under about 1 second, skeletons and spinners are not needed, and a quick flash can annoy users.

Decision When should the skeleton appear, and when may it leave?

Show immediately, hide immediately

Render the skeleton whenever isLoading is true.

Pros

  • One line of code.
  • Feedback from the very first frame.

Cons

  • Fast responses flash a skeleton for a few frames.
  • Responses near the edge flicker: grey, content, grey again on a refetch.

Delay before showing

Start a timer when loading starts. Only show the skeleton if loading is still going after about 200 to 300 ms (our pick, an estimate tuned per product).

Pros

  • Fast responses show nothing at all: the content just appears.
  • Most of the flashes disappear.

Cons

  • A response that arrives at 320 ms still flashes the skeleton for 20 ms.
  • For 200 ms the user sees no change after a click, so the click itself needs instant feedback, like a pressed state.

Delay plus a minimum display time

Same delay. But once the skeleton is shown, keep it for at least about 300 to 500 ms (an estimate), even if the data arrives sooner.

Pros

  • No flicker in any case: the skeleton is either never seen, or seen long enough to read as a deliberate state.

Cons

  • A response at 320 ms now ends at about 600 ms or later: we add up to the minimum on purpose.
  • Slightly more code: a small state machine.

Keep the old content, mark it as updating

On an update (new filter, next page, new tab), leave the old content on screen, dim it slightly or show a thin progress bar, and swap when the new data is ready.

Pros

  • Nothing disappears, nothing jumps. Feels fastest for updates.
  • React supports it directly with startTransition and useDeferredValue.

Cons

  • Only works when there is old content. The first load still needs a skeleton or nothing.
  • Stale content must look stale, or users act on old data.

What we pick: Use two of them together. For the first load of a region: show nothing for the first 200 ms, then a skeleton that stays for at least 400 ms once shown (both estimates, tuned per product with real data). For updates to a region that already shows content: keep the old content, mark it as updating, and never fall back to a skeleton. The extra wait the minimum adds is small, and it buys a screen that never flickers.

The loading states as a small machine

Write the rule as a state machine, not as a pile of booleans. A region is idle, then pending (loading, nothing shown yet), then skeleton (shown, with a timestamp), then content, empty or error. Two timers drive it: the show delay and the minimum display time. A third timer, a timeout of about 10 to 15 seconds (an estimate), moves a stuck skeleton to error so it can never spin forever.

State machine for one loading region. Idle moves to pending when a request starts; pending holds the space but shows nothing. If data arrives within 200 ms, pending goes straight to content. If 200 ms pass, pending moves to skeleton, which is shown for at least 400 ms. From skeleton, data moves to content once the minimum has passed, zero items move to empty, and a failure or a 12 second timeout moves to error. A note says retry goes back to pending with fresh timers.idleno requestpendingspace heldskeletonmin 400 mscontentdata readyemptyno itemserrorretry buttonrequest startsdata before 200 ms200 ms passdata, min met0 itemsfail or 12 s timeoutretry: back to pending,timers start fresh
Two timers and a timeout drive everything: the 200 ms show delay belongs to pending, the 400 ms minimum to skeleton, and the timeout guarantees that the skeleton always ends. Pending can also go straight to empty or error when those answers come back fast.
ts
type Phase = "pending" | "skeleton" | "done";

export function useSkeletonPhase(loading: boolean, delay = 200, minShow = 400): Phase {
  const [phase, setPhase] = useState<Phase>(loading ? "pending" : "done");
  const shownAt = useRef(0);

  useEffect(() => {
    if (loading && phase === "done") setPhase("pending");
    if (loading && phase === "pending") {
      const t = setTimeout(() => { shownAt.current = performance.now(); setPhase("skeleton"); }, delay);
      return () => clearTimeout(t);
    }
    if (!loading && phase === "pending") setPhase("done");
    if (!loading && phase === "skeleton") {
      const left = minShow - (performance.now() - shownAt.current);
      const t = setTimeout(() => setPhase("done"), Math.max(0, left));
      return () => clearTimeout(t);
    }
  }, [loading, phase, delay, minShow]);

  // Answer in the same render when `loading` flips, not one effect later.
  if (loading && phase === "done") return "pending";
  if (!loading && phase === "pending") return "done";
  return phase;
}
The timing rule as a hook. loading comes from any data source: a query library, a fetch in an effect, or a form submit. The timeout is left out to keep it short.

Work through three cases with a 200 ms delay and a 400 ms minimum.

Data at 80 ms: the timer never fires, the phase goes pending to done, and the user sees the content appear at 80 ms with no grey at all. Data at 260 ms: the skeleton appeared at 200 ms, so it stays until 600 ms; content shows at 600 ms instead of 260 ms. That 340 ms cost is the price of no flicker, and it only hits responses in a narrow band. Data at 2 s: skeleton from 200 ms to 2 s, then content; the minimum never matters.

Strategy
Timeline of 1000 ms with a response at 260 ms and the strategy skeleton now. The network row shows the request from 0 to 260 ms. The screen row shows skeleton from 0 to 260 ms, content from 260 to 1000 ms. Verdict: Flash: the skeleton shows for only 260 ms.networkscreenrequestskeletoncontent02505007501 sFlash: the skeleton shows for only 260 ms.
Try 60 ms, 260 ms and 2,000 ms with each strategy. The delay removes flashes for fast loads, the minimum removes them near the delay, and keeping old content removes the question for updates.

Check your understanding · Exercise 2

The show delay is 200 ms and the minimum display time is 400 ms. Which statements are true? Select all that apply.

Skeleton, spinner, progress bar, or the old content?

A skeleton is one of four loading indicators, and the system must say when to use each. Two questions decide it: how long is the expected wait, and do we know the layout of what is coming?

The time limits follow NN/g guidance: no indicator for very short waits, skeletons or spinners up to about 10 s, and a progress indicator for longer waits.
IndicatorUse whenAvoid whenExample
NothingThe wait is under about 200 ms (our delay)The wait is long or unknown and nothing else changes on screenOpening a cached profile
SkeletonFirst load of a region with a known layout, wait up to about 10 sActions like save or upload; layouts you cannot predictYouTube home grid, LinkedIn feed
SpinnerA small, single area or a button action with an unknown layout, about 2 to 10 sFull pages: a lone spinner gives no sense of structureA "Load more" button, a chart tile on a dashboard
Progress barA task with measurable progress (upload, export) or over about 10 s; or a thin top bar during route changesFake progress that stalls at 90%File upload percentage, the thin bar on GitHub page changes
Old content, marked updatingUpdates to a region that already shows content: filters, tabs, next pageThe first load, or when stale data is dangerous (a price at checkout)Changing a search filter, switching a tab

Product examples: a full feed loads with skeletons because the layout is known and the whole screen is waiting. A "Like" button needs no skeleton at all: it updates optimistically (shows the result before the server confirms). An export of 50,000 rows needs a real progress bar with a percentage, because it can take a minute.

Keeping the old content with React transitions

The most common skeleton bug in React apps is not on the first load. It is on the second.

A user switches from the "All" tab to the "Unread" tab, the component suspends while loading, and the nearest Suspense boundary replaces a full, readable list with a skeleton. Then the new list appears. The screen went content, grey, content: it flickered, and the user lost their place.

React has a direct answer. The React docs say that if content is already shown and the tree suspends again, the fallback shows again unless the update was caused by startTransition or useDeferredValue. During a transition, React avoids hiding content that was already revealed. So the rule for the system is: first loads may show a skeleton; updates are transitions.

tsx
function Inbox() {
  const [tab, setTab] = useState("all");
  const [isPending, startTransition] = useTransition();

  return (
    <>
      <Tabs value={tab} onChange={(next) => startTransition(() => setTab(next))} />
      <div aria-busy={isPending} className={isPending ? "opacity-70" : undefined}>
        <Suspense fallback={<MessageListSkeleton rows={8} />}>
          <MessageList tab={tab} />
        </Suspense>
      </div>
    </>
  );
}
Switching tabs inside a transition: the old list stays, dimmed, until the new one is ready. No skeleton appears.

useDeferredValue does the same for values you do not own, like a search query typed into a box: the list renders with the old query while the new one loads in the background. Frameworks with routers built on Suspense, like the Next.js App Router, already wrap navigations in transitions, so a link click keeps the old page until the new one is ready or a loading.tsx boundary is reached (more in the rendering chapter).

Sequence diagram of switching from the All tab to the Unread tab. The user clicks Unread. The tabs call startTransition. React reads tab unread from the query cache, which sends GET with tab unread to the API. React reports isPending true to the tabs, which dim the old list (labelled pending: dim). The API replies 200 with 8 messages, the cache passes the data to React, and React swaps the list in one step with no skeleton.UserTabsReactQuery cacheAPIclick Unreadtransitionread unreadGET unreadpending: dim200, 8 itemsdata readyswap list
Because the update runs inside startTransition, React keeps the already revealed list on screen instead of falling back to the skeleton. The user only sees the old list dim for a moment, then the new one.

Check your understanding · Exercise 3

Pick the right indicator for each case. Which pairs are good choices? Select all that apply.

Layout fidelity: shapes that match, so nothing jumps

The skeleton's second promise is position. If the skeleton card is 120 px tall and the real card is 220 px, then when the data arrives, everything below the first card moves down 100 px. A user about to tap the second card taps something else. Google measures this as Cumulative Layout Shift (CLS), one of the Core Web Vitals; a good score is 0.1 or less.

The math of one wrong card

Each layout shift gets a score: impact fraction times distance fraction. The impact fraction is the share of the viewport covered by the moving elements, before and after the move together. The distance fraction is how far they moved, divided by the viewport's larger side.

Only elements that were already on screen and change their start position count. Content that replaces a skeleton is new to the page, so it is not a shift by itself. What counts is everything already rendered below it that moves. So the worst case is a skeleton above content that is already there.

  • Phone viewport: 390 by 844 px. A 64 px header, then a promoted post with its own request, then full-width feed cards that are already on screen.
  • The promoted post's skeleton is 120 px tall, so the cards below start at 184 px. The real post is 220 px, so they now start at 284 px.
  • Impact: the moving cards covered 184 to 844 px before and 284 to 844 after. Together that is 660 of 844 px: 0.78.
  • Distance: 100 px out of 844: 0.118.
  • Score: 0.78 x 0.118 = 0.09. One wrong card uses almost the whole "good" budget of 0.1, on every page load.

Two subtleties. First, shifts within 500 ms after a user input (a click or a key press) are flagged and left out of CLS, so a swap right after a tab click often does not count. The first page load, and anything that arrives later like a slow second request, does count. Second, CLS is not a sum over the whole visit: shifts less than 1 s apart form a burst (a session window, at most 5 s long), and CLS is the score of the worst burst.

So two wrong skeletons that resolve close together add up in one burst. And when a whole list swaps at once with nothing below it, CLS may stay near zero while users still see the cards jump. Do not rely on either detail: match the shapes.

Skeleton card
Animation
A 390 by 844 phone feed before and after the top slot loads. Below a 64 px header, the top slot shows a skeleton 120 px tall, then becomes a real post 220 px tall; the full-width feed cards below it were already on screen. Every card below moves down 100 px. Impact fraction 0.78 times distance fraction 0.118 gives a layout shift score of 0.093, against a good CLS budget of 0.1. Animation: shimmer.top slot loadingtop slot loadedcard 2: +100 px0.78 x 0.118 = 0.093 of the 0.1 budget
The top slot is a post with its own request; the feed cards below it are already on screen, and the real post is always 220 px. Pick a skeleton height and follow the dashed line from card 2's top before to its top after: any slope is a layout shift. Reduced motion stops the sweep and the pulse; the shapes stay.

Reserving space, shape by shape

ContentHow the skeleton reserves its spaceCommon mistake
Media (thumbnail, photo)aspect-ratio: 16 / 9 with width: 100%, the same rule the real image uses; the server sends width and height so the real image keeps itA fixed height: 200px that is right only on one screen width
TextN bars, each in a box one line-height tall (the CSS lh unit), with realistic widths like 100%, 94%, 60%One full-width bar for a three-line title
AvatarA circle with the same size token as the real avatar, like size="md" (40 px)A 32 px circle for a 40 px avatar
Buttons and chipsA rounded rectangle of the button's height; width is a guess, which is fine because it does not push anythingLeaving them out, so the row is shorter than the real one
Variable content (comments, a long bio)Clamp the real content to a fixed number of lines, or reserve the typical height and let the rest grow below the foldA skeleton for a 2-line bio when bios range from 0 to 12 lines

Do not over-promise. A skeleton should draw the parts that are always there: avatar, name, the text block, the media box. It should not draw a "Sponsored" badge, a poll, or a row of three comment previews that appear on one post in ten.

Those cause the opposite bug: a gap that collapses when the real content arrives without them, which is also a shift. If an optional part changes the height, either reserve its space always in the real card, or put it where a change does not move other things, like the end of the card.

How many skeleton cards?

  • Fill the viewport: Math.ceil(viewportHeight / cardHeight), about 4 cards on our phone. More is wasted, since nobody sees them, and fewer leaves a blank area at the bottom that fills later.
  • Use the last known count: for a list that is usually small, like "3 saved addresses", remember the count from the last visit in the query cache or sessionStorage and draw that many.
  • Ask the server: when the count arrives earlier than the items, as in streaming responses, draw exactly that many.
  • Accept the end shift: if the real list has 2 items where we drew 4, the area below shrinks. Put the list last in its column, or give the region a minimum height, so nothing important sits below it.

Think first

Your team's card shows a 2-line title on desktop and a 3-line title on phones, because the text wraps. How should the skeleton handle it?

Check your understanding · Exercise 4

Which skeleton choices keep the layout stable when the content arrives? Select all that apply.

The component API: primitives, composition, tokens

The naive API is one div with a grey background that every team sizes by hand. That is how we got five kinds of loading state.

The libraries teams already know show the space of choices: MUI has one Skeleton with a variant (text, circular, rectangular, rounded) and an animation prop (pulse by default, wave, or false). Chakra UI has Skeleton, SkeletonCircle and SkeletonText with noOfLines, plus a loading prop that wraps the real content. Ant Design has a Skeleton with avatar, paragraph rows and an active flag. shadcn/ui ships a single div with Tailwind's animate-pulse, rounded corners and a muted background, and leaves the rest to you.

Decision How do teams build the skeleton for their component?

Hand-made, next to the real component

Small primitives (Skeleton.Text, Skeleton.Circle, Skeleton.Rect). Each team writes PostCard.Skeleton from them, in the same file as PostCard.

Pros

  • Full control: draws only the parts that are always there.
  • Tiny and fast: no real component, no data, no hooks.
  • Lives next to the real card, so a review of one shows the other.

Cons

  • Can drift from the real card when someone edits one and not the other.
  • One more thing to write per component.

Automatic, from the real component with a loading prop

Render the real PostCard with placeholder data, and a loading flag that turns every text and image into a grey shape (Chakra's wrapping Skeleton, MUI's skeleton that takes its size from children).

Pros

  • Always the right size: it is the real layout.
  • No second component to maintain.

Cons

  • Needs fake data that is the right length, or the shape is wrong anyway.
  • Runs the real component's code, hooks and child components just to draw grey.
  • Draws every optional part, over-promising content that is not there.

Generated at build time from a snapshot

A tool renders the real component, measures each box, and writes a static skeleton.

Pros

  • Pixel-accurate for the snapshot data.

Cons

  • Wrong for every other screen width and data length.
  • A build step and a tool to maintain for little gain.

What we pick: Hand-made skeletons from shared primitives, co-located with the real component as PostCard.Skeleton, guarded by a test that checks their heights match (below). Offer the loading wrapper only for simple leaf parts like a single line of text, where the real element's size is already known. This keeps skeletons tiny and honest, and the test catches drift.

tsx
export const Skeleton = {
  Text: ({ lines = 1, widths = ["100%", "94%", "60%"] }: TextProps) => (
    <span className="sk-text" aria-hidden="true">
      {Array.from({ length: lines }, (_, i) => (
        <span key={i} className="sk sk-line" style={{ width: widths[i % widths.length] }} />
      ))}
    </span>
  ),
  Circle: ({ size }: { size: AvatarSize }) => <span className={`sk sk-circle avatar-${size}`} aria-hidden="true" />,
  Rect: ({ ratio, height }: RectProps) => (
    <span className="sk" aria-hidden="true" style={{ aspectRatio: ratio, height }} />
  ),
};

PostCard.Skeleton = function PostCardSkeleton() {
  return (
    <div className="post-card">
      <div className="post-card-header"><Skeleton.Circle size="md" /><Skeleton.Text lines={2} widths={["40%", "25%"]} /></div>
      <Skeleton.Text lines={3} />
      <Skeleton.Rect ratio="16 / 9" />
    </div>
  );
};
Primitives plus one co-located composite. All sizes come from the same tokens as the real card.

Note that PostCard.Skeleton reuses the real card's layout classes (post-card, post-card-header). Padding, gaps and widths then come from one place. Only the leaves differ: grey shapes instead of text and images.

Tokens

  • Color: --sk-base and --sk-highlight, built from the theme's own tokens (see the dark-mode-theming guide for how themes switch). Mixing a little of the text color into the surface color with color-mix() gives a base that is a bit darker than the surface in light mode and a bit lighter in dark mode.
  • Radius: --sk-radius-text (small, like 4 px) and the real component's radius for media, so a rounded thumbnail has a rounded placeholder.
  • Motion: --sk-duration (about 1.5 s per sweep, an estimate) and --sk-animation (shimmer, pulse or none).
  • Timing: delay and minShow defaults for the hook (200 ms and 400 ms), so every team uses the same rule.

Keeping skeleton and component in sync

A visual test catches drift. In the component's test (Playwright or Storybook with a test runner), render PostCard with typical data and PostCard.Skeleton side by side at three widths, 360, 768 and 1280 px, and assert that their heights differ by less than about 4 px. When someone adds a row to the card and forgets the skeleton, the test fails in their pull request, not in production CLS data two weeks later.

The system's parts

PartResponsibilityWhy the boundary is here
Primitives (Skeleton.Text, Circle, Rect)Draw grey shapes; aria-hidden; read tokensOne place for color, radius and animation
Composites (PostCard.Skeleton)Arrange primitives like the real componentCo-located with the real component so they change together
LoadingRegionRuns the timing machine; holds space; sets aria-busy; renders the status message; chooses skeleton, content, empty or errorOne place for timing and accessibility rules, so teams cannot get them wrong
useSkeletonPhase hookThe timers: delay, minimum, timeoutUsable without the region, for custom layouts
Data layer (query cache, Suspense)Says whether data is loading, ready, empty or failedThe skeleton system reads it; it never fetches
Component diagram of the skeleton system. The design system zone holds tokens for color and motion, the primitives Text, Circle and Rect, and LoadingRegion, which owns timing, ARIA and exits. The feature team zone holds PostCard, the real card, and PostCard.Skeleton in the same file. Tokens feed the primitives, PostCard.Skeleton uses the primitives, and LoadingRegion renders the skeleton while loading, or the real card when ready. The data layer zone holds the query cache with status and data, and the API client with fetch and abort. The API client sends JSON to the query cache, and the cache sends status and data up to LoadingRegion.design systemfeature teamdata layertokenscolor, motionprimitives3 shapesLoadingRegiontiming, ARIA, exitsPostCard.Skeletonsame filePostCardreal cardquery cachestatus, dataAPI clientfetch + abortusesskeletonreadystatus, dataJSON
The design system owns rules (timing, accessibility, tokens); feature teams own shapes, next to their real component. Neither fetches: they read status from the data layer.
Data flow for the feed. On the first load, the API sends JSON rows to the server renderer, which streams HTML to the browser: first the shell with the skeleton fallback, then the feed HTML. The browser hydrates and fills the query cache. The query cache gives a status to LoadingRegion, which draws shapes or content on the screen. On an update, the query cache sends a refetch request to the API while the old content stays on screen. Notes say the first load uses fallback HTML and updates keep the old content and refetch.APIfeed rowsSSR serverrenders shellbrowserpaints HTMLscreenshapes or feedLoadingRegionpicks phasequery cacheclient dataJSONstreamhydratestatusshapesrefetch on updatefirst load: fallback HTML, then feedupdate: old content stays, refetch
The first load's skeleton comes from the server as plain HTML; later loading states come from the client cache's status. In both, LoadingRegion is the only part that decides what the screen shows.

Check your understanding · Exercise 5

Why do we prefer hand-made skeletons from shared primitives over rendering the real component with a loading prop? Select all that apply.

Animation: shimmer or pulse, cheap and in step

A still grey box can look frozen after a few seconds, so most systems animate it. NN/g notes that animated skeletons signal that work is still going on, but also that motion can distract or cause problems for some users. Two styles dominate: a pulse (the shapes fade between two greys, MUI's and shadcn's default) and a shimmer (a light band sweeps across, like Facebook's and MUI's wave).

What each animation costs the browser

Every frame, the browser may have to do layout (compute sizes), paint (fill pixels) and composite (stack painted layers). web.dev's animation guide advises sticking to transform and opacity, which the compositor can animate without new layout or paint. The lesson reflow-repaint-composite explains why. The naive shimmer animates background-position on a gradient. That changes pixels inside the element, so in most browsers each frame repaints every skeleton on the main thread: exactly the thread that is busy loading the page.

Decision How do we animate 60 shapes without slowing the page down?

Shimmer with background-position

A wide gradient background on each shape; keyframes move background-position from right to left.

Pros

  • Simple, one element per shape, common in tutorials.

Cons

  • Repaints every shape on every frame on the main thread.
  • Janky exactly when the page is busiest.

Shimmer with a moving pseudo-element

Each shape has overflow: hidden and an ::after holding the gradient; keyframes move it with transform: translateX(...).

Pros

  • Runs on the compositor: no layout or paint per frame.
  • Same look as the background version.

Cons

  • Each animated pseudo-element may become its own compositor layer, which costs GPU memory with hundreds of shapes.

Pulse with opacity

Keyframes fade each shape between opacity 1 and 0.5.

Pros

  • Compositor-friendly and the simplest.
  • Calmer than a sweep; a good fallback for low-end devices.

Cons

  • Less sense of direction; some find a slow blink less lively.

What we pick: Default to the transform shimmer on a pseudo-element, with opacity pulse as a token option and as the low-end fallback. Never animate background-position, width or left. Keep the count of shapes small (fill the viewport, not the page), which also limits the layer cost.

css
/* Built on the theme tokens, so every theme gets a matching base. */
:root,
[data-theme] {
  --sk-base: color-mix(in srgb, var(--color-text) 8%, var(--color-surface));
  --sk-highlight: rgb(255 255 255 / 0.5);
  --sk-radius-text: 4px;
  --sk-duration: 1.5s;
}
[data-theme="dark"] {
  --sk-highlight: rgb(255 255 255 / 0.08);
}
.sk {
  --sk-phase: 0ms;
  position: relative;
  overflow: hidden;
  background: var(--sk-base);
  border-radius: var(--sk-radius-text);
}
.sk::after {
  content: "";
  position: absolute;
  inset: 0;
  background: linear-gradient(90deg, transparent, var(--sk-highlight), transparent);
  transform: translateX(-100%);
  animation: sk-shimmer var(--sk-duration) linear infinite;
  animation-delay: var(--sk-phase);
}
@keyframes sk-shimmer {
  to { transform: translateX(100%); }
}
@media (prefers-reduced-motion: reduce) {
  .sk::after { animation: none; display: none; }
}
Skeleton tokens built on the theme tokens from the dark-mode-theming guide, one compositor-only animation synced by a negative delay from a shared clock, and no sweep for reduced motion.

One timeline: why skeletons shimmer out of step

A CSS animation starts when its element appears. Cards that mount at different times, say 4 cards at 200 ms and 2 more at 450 ms after a second request, sweep out of step: the page looks like rippling water instead of one calm wave. Three ways to keep them together:

  • Negative delay from a shared clock: at mount, set --sk-phase to -(performance.now() % duration) ms. Every animation then behaves as if it started at time zero, so all are at the same point of the sweep. It costs one style write per region, not per frame.
  • Web Animations API: start each shape with element.animate(...) and set the animation's startTime to the same value, such as 0, on the document timeline.
  • A fixed background: give every shape a gradient with background-attachment: fixed, so the gradient is positioned against the viewport and all shapes show slices of one band. It is in sync for free, but moving that band means animating background-position, which repaints. Safari on iOS also treats fixed as scroll, so on iPhones the shapes fall out of step again. Use it only for a few shapes on desktop.
Two panels of three skeleton bars, frozen at the same moment. On the left, each bar's shimmer started when its card mounted, at 0, 250 and 900 ms, so the light bands sit at three different positions: out of step. On the right, each bar's animation delay is set to minus the current time modulo the 1500 ms duration, so all three bands sit at the same position: in step.own start timesshared clockcard 1, mounted at 0 mscard 2, mounted at 250 mscard 3, mounted at 900 msphase = -(now % 1500)out of stepin step
A CSS animation counts from the moment its element appears. Setting a negative animation-delay from a shared clock makes every skeleton act as if it started at time zero, so late cards join the same wave.

Reduced motion, dark mode and visibility

  • Reduced motion: when prefers-reduced-motion: reduce matches, drop the sweep. Show static shapes, or at most a very slow, gentle opacity change. Static shapes still say "loading" thanks to the status message (next chapter).
  • Dark mode: a light-grey skeleton on a dark page glows. Use theme tokens: the base a few steps lighter than the dark surface, the highlight a step lighter still.
  • Visibility: skeletons are decorative, so WCAG's contrast minimums do not strictly apply to them. They still have to be visible. Aim for a base that is clearly distinct from the surface, and check it on a cheap, dim phone screen, not only on a design monitor.
  • Long waits: after about 10 s (an estimate), stop the shimmer and show a message like "Still loading..." with a retry. Endless motion with no news reads as a hang.

Check your understanding · Exercise 6

Which shimmer implementations avoid repainting every shape on every frame? Select all that apply.

Accessibility: quiet shapes, one clear message

To a screen reader, a skeleton built from divs is either silence or noise. If the shapes carry no text, the user hears nothing and does not know the page is loading. If someone added aria-label="Loading" to each shape, the user hears "Loading, loading, loading" thirty times. The lesson screen-readers shows how a user moves through a page; the rule here is simple: the shapes are decorative, the region speaks once.

  • Hide every shape: aria-hidden="true" on primitives. They contain no information.
  • Mark the region busy: aria-busy="true" on the container while it loads, false (or removed) when done. MDN describes it as telling assistive technology that the element is being updated and it may wait before exposing the changes. Support differs between screen readers, so treat it as a hint, not as the only signal.
  • Announce once: a visually hidden element with role="status" (a polite live region) that is always in the page, and whose text changes to "Loading messages" when the skeleton shows, is cleared when the content arrives, and changes to "Could not load messages" on an error. One status per region, not per shape. The aria-patterns lesson covers live regions.
  • Nothing to tab into: shapes are spans, never buttons or links, so keyboard users cannot land on a placeholder. If the real card is a link, the skeleton is not.
  • Do not steal focus: when content replaces the skeleton, focus stays where the user left it. Moving focus into new content yanks a keyboard user away from what they were doing. One exception is a page the user just navigated to: there the app or its router moves focus to the new page's heading, once. The other is when the focused element itself disappears, like a Retry button replaced by content: move focus to the region's heading so it does not fall back to the page body.
tsx
function LoadingRegion({ label, state, skeleton, children, onRetry }: RegionProps) {
  const phase = useSkeletonPhase(state.status === "loading");
  const message =
    phase === "skeleton" ? `Loading ${label}`
    : state.status === "error" ? `Could not load ${label}`
    : "";

  return (
    <section aria-label={label} aria-busy={state.status === "loading"}>
      <p role="status" className="sr-only">{message}</p>
      {phase === "pending" && <div style={{ minHeight: skeleton.height }} />}
      {phase === "skeleton" && skeleton.node}
      {phase === "done" && state.status === "error" && <ErrorBlock onRetry={onRetry} />}
      {phase === "done" && state.status === "empty" && <EmptyBlock label={label} />}
      {phase === "done" && state.status === "ready" && children}
    </section>
  );
}
The region owns the accessibility rules, so composites only draw shapes.

The status text is empty for fast loads, because the skeleton never showed: nothing changed visibly, so nothing is announced. A load that took 2 s gets "Loading messages" once, and nothing after a success, because the content itself is now there to read. Some teams also announce a short "Messages loaded" after a slow load; if you do, send it only when "Loading" was announced. Errors are always announced.

Sequence diagram of a slow first load. The user opens the inbox. The region sends GET messages to the API. After 200 ms the region shows the skeleton, sets aria-busy, and writes Loading messages into the status element, which the screen reader announces once. At 1.8 seconds the API replies 200 with 12 items. The region swaps in the content while focus stays where it was, then clears the status text and sets aria-busy to false.UserRegionStatusAPIopen inboxGET /messages200 ms: Loadingannounce once200, 12 itemsswap, keep focusclear message
One message per region, not per shape. The shapes are aria-hidden, so the status element is the only thing a screen reader hears, and the content swap never moves focus.

Check your understanding · Exercise 7

A feed shows 4 skeleton cards with 5 shapes each. What should a screen reader user experience? Select all that apply.

Fitting the rendering model: Suspense, streaming and routes

With client-side fetching, the skeleton appears after the JavaScript bundle loads and the component runs. With streaming server rendering (React's renderToPipeableStream or a framework like Next.js), it is earlier: the server sends the HTML of the page shell with each Suspense fallback in place, then streams the real HTML for each boundary when its data is ready, with a small inline script that swaps it in. The React docs note that a boundary also shows its fallback while its HTML is still streaming in. The lessons streaming-ssr and ssr cover the mechanics.

This changes two things. The skeleton is in the first paint, before any JavaScript, so it must be pure HTML and CSS: no timers, no useEffect. And the show delay does not apply to the first load, since the server cannot know whether the data will arrive in 50 ms. It is still fine, because the server only sends a fallback if the data is not ready when the shell is flushed.

Where to put the boundaries

Decision How many Suspense boundaries, and where?

One boundary around the whole page

A single fallback: a skeleton of the full page.

Pros

  • Simplest; content appears all at once.

Cons

  • The slowest request holds back everything. The header and nav, which need no data, become grey too.

One boundary per component

Every card, widget and sidebar block has its own boundary.

Pros

  • Each part appears as soon as its data is ready.

Cons

  • Content pops in piece by piece in random order. Many small shifts, and a busy, flickering page.

One boundary per meaningful region

The shell (header, nav) renders without a boundary. The main feed, the sidebar and the comments each get one boundary with a region skeleton.

Pros

  • Readable as soon as the main region is ready.
  • A small, predictable number of reveals.

Cons

  • Needs thought about which data belongs together.

What we pick: One boundary per meaningful region. The shell never suspends. Reveal regions in reading order where you can (main content before the sidebar), and nest boundaries only where a part is much slower, like comments under a video.

React already smooths small gaps. Its docs say it reveals suspended content at most once every 300 ms, measured from the last reveal, so boundaries that become ready within that window appear together. That hides a 50 ms difference between two regions, but not a 2 s gap between a video and its comments.

Watch for waterfalls: a boundary whose child fetches, then renders a grandchild that fetches again, behind a second nested boundary. The user sees skeleton, then partial content with a smaller skeleton, then the rest: two loads in series. Start both requests at the region level (or in the route loader) so they run in parallel, and keep one boundary. The lesson server-state-libraries covers prefetching and caching.

A video page with three Suspense boundaries, B1 for the video, B2 for the comments and B3 for recommendations; the header is outside any boundary. On the right, the order of chunks in one streamed HTML response: at 0 ms the shell with three fallbacks, at 300 ms the video HTML for B1, at 900 ms the recommendations HTML for B3, and at 2.5 seconds the comments HTML for B2.video pageB1 videoB3 recsB2 comments0 ms: shell + 3 fallbacks300 ms: B1 video900 ms: B3 recs2.5 s: B2 commentsone HTTP response,streamed in chunks
The shell and all three skeletons arrive in the first chunk, before any JavaScript. Each region then fills in as its data is ready, so the slow comments never hold back the video.

Route changes: keep the old page, or show a skeleton route?

In the Next.js App Router, a loading.tsx file in a route folder wraps that route's page in a Suspense boundary with the file's content as fallback. The Next.js docs call it an instant loading state: it is prefetched, so on navigation it shows immediately while the page streams in. Without loading.tsx, the navigation is a transition: the old page stays until the new one is ready.

  • Keep the old page with a thin top progress bar when most navigations are fast (under about 500 ms, an estimate) or when going back is common. Nothing goes grey.
  • Show a skeleton route (loading.tsx) for routes whose data is usually slow, so the click is answered at once with the new page's shape.

Every skeleton must end

A skeleton waiting for a request that failed is the worst state of all: it says "almost there" forever. Three exits are required: content, an empty state ("No messages yet", with an action), and an error with a retry button.

With Suspense, the error exit is an error boundary placed next to each region's Suspense boundary. With client fetching, the region's state machine handles it.

And a timeout (about 10 to 15 s, an estimate, tuned per endpoint) turns a stuck skeleton into "This is taking longer than usual" with retry, even if the request never fails on its own. A request cancelled because the user left, by an AbortController, should end quietly, not as an error; see request-races.

Sequence diagram of a failed load and a retry. The region sends GET comments to the API and shows the skeleton after 200 ms. The API replies 503. The region replaces the skeleton with an error message and a Retry button, and the status announces Could not load comments. The user clicks Retry. The region sends GET comments again, the API replies 200, and the region shows the comments and moves focus to the region heading, because the Retry button that had focus is gone.UserRegionAPIGET /comments200 ms: skeleton503 unavailableerror + Retryclick RetryGET /comments200 OKcontent, focus heading
The skeleton never waits on a failed request: the error replaces it with a way out. After a retry, the button that held focus disappears, so focus moves to the region's heading instead of falling back to the page body.

Check your understanding · Exercise 8

A video page streams with one Suspense boundary around everything. The comments API takes 2.5 s; the video data takes 300 ms. What improves it? Select all that apply.

Measuring it, and running it in the interview

How you know the system works

Do skeletons make waits feel shorter? Luke Wroblewski popularized the idea in 2013: in his team's Polar app, adding spinners made people say the app felt slow, so they switched to placeholders that fill in as content arrives. NN/g also says skeletons reduce perceived load time and cognitive load for full-page loads.

The evidence is mixed, though. In a small 2017 Viget test with 136 participants and loading animations of the same length, people who saw a skeleton guessed the wait was longer than those who saw a spinner or a blank screen. Results depend on the design, the wait and how familiar the interface is. So do not sell skeletons as a speed trick: sell them as layout stability plus a clear, calm state, and measure in your own product.

  • CLS by region: the PerformanceObserver for layout-shift entries lists the moved elements in sources. Tag regions with a data attribute and report which region caused each shift. Target: skeleton regions add almost nothing to the 0.1 budget.
  • Skeleton exposure: marks for "skeleton shown" and "content shown" per region. Report the share of loads that showed a skeleton at all (a fast app should be low) and how long it stayed (p50 and p95).
  • Stuck skeletons: count timeouts and errors per endpoint. A rising count is an outage signal, not a design problem.
  • Perceived performance: rage clicks (repeated clicks on the same spot) during loading, and drop-off before content appears. A/B test pulse against shimmer, or skeleton against keeping old content, on these numbers.

A 30-minute plan

MinutesWhat to doWhat to draw
0 to 4Scope: response times, known layout or not, rendering model, frameworks, who uses itNothing yet; write the numbers down
4 to 10Timing: the flash problem, Nielsen's limits, delay plus minimum, transitions for updatesThe state machine: pending, skeleton, content, empty, error
10 to 15Fidelity: CLS math for one wrong card, aspect ratio, line counts, how many cardsA card next to its skeleton, with heights
15 to 21API: primitives, co-located composites, LoadingRegion, tokens, the drift testThe component diagram
21 to 26Animation and accessibility: transform shimmer, sync, reduced motion, aria-hidden, one status messageThe CSS for one shape
26 to 30Suspense and streaming boundaries, errors and timeouts, metricsThe page with its boundaries marked

The strongest signal in this interview is saying when not to show a skeleton. Most candidates design the grey box. Seniors open with "a skeleton for a 50 ms load is a bug" and "updates should keep the old content", then show the numbers.

Think first

Follow-up: "Our data is cached, so most loads take 0 ms. Do we still need skeletons?"

Think first

Follow-up: "A team says the skeleton is pointless because the page is server-rendered with all data. What do you say?"

Check your understanding · Exercise 9

Which statements would you defend in the interview? Select all that apply.

Summary

  • A skeleton promises the shape and position of what is coming. The whole system is about keeping that promise.
  • Show nothing for fast loads. Show a skeleton only after a short delay (about 200 ms), and keep it for a minimum time (about 400 ms) once shown, so it never flickers.
  • First loads may show a skeleton; updates keep the old content inside startTransition or useDeferredValue, dimmed and marked busy.
  • Match the final layout: aspect-ratio for media, line-height boxes and clamped lines for text, only parts that are always there, and about a viewport's worth of cards.
  • Build hand-made skeletons from shared primitives, co-located with the real component, and test that their heights match at several widths.
  • Animate with transform or opacity, on one shared timeline, and turn the shimmer off for prefers-reduced-motion.
  • Shapes are aria-hidden; the region sets aria-busy and announces once through a role="status" message; focus moves only on a route change or when the element that had it disappears.
  • Place one Suspense boundary per meaningful region, never around the shell, and pair each with an error boundary and a timeout, so every skeleton ends.

Check your answer

Tick what your own design covered. Pick the level you're interviewing for: each level includes the ones before it.

Junior 0 of 3
Mid-level 0 of 4

References

Concepts behind it

Try next