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
PostCardskeleton, 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
| Requirement | Target | Why |
|---|---|---|
| Layout stability | Cumulative Layout Shift (CLS) at or below 0.1 for 75% of page loads | That is Google's "good" threshold. A skeleton of the wrong height is a top cause of shifts. |
| No flicker | A 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 animation | Shimmer runs on the compositor; 50 skeletons on screen cost no main-thread work per frame | Loading is exactly when the main thread is busiest. |
| Accessibility | One "Loading" announcement per region, none per shape; no focusable placeholders | A screen reader must not hear 30 empty boxes. |
| Reduced motion | No moving shimmer when the user asks for reduced motion | Some users get dizzy or distracted by motion. |
| Size | Under 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).
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?
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.
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;
}
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.
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?
| Indicator | Use when | Avoid when | Example |
|---|---|---|---|
| Nothing | The wait is under about 200 ms (our delay) | The wait is long or unknown and nothing else changes on screen | Opening a cached profile |
| Skeleton | First load of a region with a known layout, wait up to about 10 s | Actions like save or upload; layouts you cannot predict | YouTube home grid, LinkedIn feed |
| Spinner | A small, single area or a button action with an unknown layout, about 2 to 10 s | Full pages: a lone spinner gives no sense of structure | A "Load more" button, a chart tile on a dashboard |
| Progress bar | A task with measurable progress (upload, export) or over about 10 s; or a thin top bar during route changes | Fake progress that stalls at 90% | File upload percentage, the thin bar on GitHub page changes |
| Old content, marked updating | Updates to a region that already shows content: filters, tabs, next page | The 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.
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>
</>
);
}
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).
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 of0.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.
Reserving space, shape by shape
| Content | How the skeleton reserves its space | Common 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 it | A fixed height: 200px that is right only on one screen width |
| Text | N 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 |
| Avatar | A 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 chips | A rounded rectangle of the button's height; width is a guess, which is fine because it does not push anything | Leaving 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 fold | A 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
sessionStorageand 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?
Let the same CSS decide. Render the skeleton's title block with the same width rules and a line count that also changes with a media query, or clamp the real title to two lines everywhere (-webkit-line-clamp: 2, which all major browsers support) with a minimum height of 2lh, so both are always two lines tall.
Clamping is simpler and more stable: the skeleton then never has to guess how the text wraps. Many feeds do exactly this for titles.
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?
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.
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>
);
};
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-baseand--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 withcolor-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,pulseornone). - Timing:
delayandminShowdefaults 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
| Part | Responsibility | Why the boundary is here |
|---|---|---|
Primitives (Skeleton.Text, Circle, Rect) | Draw grey shapes; aria-hidden; read tokens | One place for color, radius and animation |
Composites (PostCard.Skeleton) | Arrange primitives like the real component | Co-located with the real component so they change together |
LoadingRegion | Runs the timing machine; holds space; sets aria-busy; renders the status message; chooses skeleton, content, empty or error | One place for timing and accessibility rules, so teams cannot get them wrong |
useSkeletonPhase hook | The timers: delay, minimum, timeout | Usable without the region, for custom layouts |
| Data layer (query cache, Suspense) | Says whether data is loading, ready, empty or failed | The skeleton system reads it; it never fetches |
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?
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.
/* 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; }
}
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-phaseto-(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'sstartTimeto 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 animatingbackground-position, which repaints. Safari on iOS also treatsfixedasscroll, so on iPhones the shapes fall out of step again. Use it only for a few shapes on desktop.
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: reducematches, 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.
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 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.
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?
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.
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.
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
PerformanceObserverforlayout-shiftentries lists the moved elements insources. Tag regions with a data attribute and report which region caused each shift. Target: skeleton regions add almost nothing to the0.1budget. - 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
| Minutes | What to do | What to draw |
|---|---|---|
| 0 to 4 | Scope: response times, known layout or not, rendering model, frameworks, who uses it | Nothing yet; write the numbers down |
| 4 to 10 | Timing: the flash problem, Nielsen's limits, delay plus minimum, transitions for updates | The state machine: pending, skeleton, content, empty, error |
| 10 to 15 | Fidelity: CLS math for one wrong card, aspect ratio, line counts, how many cards | A card next to its skeleton, with heights |
| 15 to 21 | API: primitives, co-located composites, LoadingRegion, tokens, the drift test | The component diagram |
| 21 to 26 | Animation and accessibility: transform shimmer, sync, reduced motion, aria-hidden, one status message | The CSS for one shape |
| 26 to 30 | Suspense and streaming boundaries, errors and timeouts, metrics | The 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?"
Yes, but rarely shown. With a delay, cached loads never show a skeleton, so the system costs nothing there. It still matters for the cold first visit and for cache misses, which are the slowest loads and the ones users remember.
Also check stale-while-revalidate: if the cache shows old data and refetches, that is an update, so it uses the "keep old content" path with a subtle indicator, never a skeleton.
Think first
Follow-up: "A team says the skeleton is pointless because the page is server-rendered with all data. What do you say?"
They are right for that page: if the server waits for all data, there is nothing to stand in for. The cost is a slower first byte and first paint, since the slowest query holds the whole page.
With streaming, the shell and fast regions arrive early and slow regions stream in behind a skeleton. Whether that is better depends on the slow part: worth it for comments under a video, not for a 40 ms query.
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
startTransitionoruseDeferredValue, dimmed and marked busy. - Match the final layout:
aspect-ratiofor 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
transformoropacity, on one shared timeline, and turn the shimmer off forprefers-reduced-motion. - Shapes are
aria-hidden; the region setsaria-busyand announces once through arole="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.
References
- Response Times: The 3 Important Limits (Nielsen Norman Group) The 0.1 s, 1 s and 10 s limits behind the timing rules.
- Skeleton Screens 101 (Nielsen Norman Group) When to use skeletons, spinners and progress bars.
- Mobile Design Details: Avoid the Spinner (Luke Wroblewski) Where the skeleton screen idea comes from.
- A Bone to Pick with Skeleton Screens (Viget) A small 2017 test where skeletons felt slower than a spinner.
- Suspense (React docs) Fallbacks, nested boundaries, and keeping revealed content during transitions.
- useTransition (React docs)
- loading.js (Next.js docs) Instant loading states for route segments.
- Cumulative Layout Shift (web.dev) How layout shift scores are computed, and the 0.1 threshold.
- How to create high-performance CSS animations (web.dev)
- aria-busy (MDN)
- prefers-reduced-motion (MDN)
- Skeleton API (MUI)
Concepts behind it
- Core Web VitalsThree numbers, measured on real users, that say whether a page loads fast (LCP), reacts fast (INP), and stays still (CLS): what each one measures, what makes it bad, and how to fix it.
- Loading strategiesDeciding what the browser loads first, what it loads later, and what it loads only when needed, so the pixels that matter show up fast.
- Streaming server renderingThe server sends a page's HTML in pieces as each part becomes ready, instead of waiting for its slowest data: the layout paints right away, slow sections stream in later, and each piece hydrates on its own.
- Layout, paint, and compositingWhat the browser redoes when something on the page changes after the first render, why moving a box with
transformis cheap while changing itswidthis not, and how to keep scrolling and animation inside a 16 ms frame. - Designing component APIsHow to shape a component's props, children, slots, events, and ref so other engineers can use it for cases you didn't predict, without it growing a hundred props: composition, compound components, controlled state, and headless behavior.
- ARIA widget patternsThe tested recipes for custom widgets from the ARIA Authoring Practices Guide: combobox, listbox, menu, tabs, dialog, tree, grid, disclosure, and accordion, with the roles, states, and keys each one promises, and how to pick the right one.
- Screen readers and live regionsHow screen readers turn a page into speech or braille, how every control gets its accessible name and description, and how to announce changes like search results, saves, and errors with live regions.
- Design systemsA shared set of design tokens, accessible components, patterns, docs, and rules that many teams build on, so a company ships one consistent product instead of many slightly different ones, and can change its look in one place.