React Skeleton Loaders: 91 Components, 3 Loading States, Zero Shimmer
An audit of every loading state in this codebase — the shaped placeholder that replaces a skeleton, and the derived-state trick that stops one flashing.
A React skeleton loader earns its place only when you already know the shape of what is arriving and the wait is long enough to be noticed. This codebase has 91 components, 27 of them Client Components, and exactly three that render anything while waiting — none of which is a shimmer skeleton. Here is the rule that produced that count, and the derived-state bug that makes skeletons flash.
The three are all in src/components/molecules/: PurchaseCta.tsx, BuyButton.tsx and PreviewModal.tsx. Every other component either renders on the server with its data already resolved, or renders a real answer immediately and revises it. That ratio is the actual lesson of this post — most loading states in a React app are a symptom of putting the fetch in the wrong place, and a skeleton is a nicer-looking way of not fixing that.
Skeleton, spinner, shaped placeholder, or nothing
| Shimmer skeleton | Spinner | Shaped placeholder | Render the default | |
|---|---|---|---|---|
| Reserves final layout | Yes | No | Yes | Yes |
| Communicates what is coming | Yes | No | Partly | N/A |
| Cost to build | Per-component, must track the real layout | One component, reused | One extra branch per component | Zero |
| Goes stale when the layout changes | Yes — silently | No | No — it is the layout | No |
| Right for | Content-shaped regions: article bodies, card grids, table rows | One opaque region whose contents you cannot predict | A single control whose final geometry you know | Answers that are the same for almost every visitor |
| Wrong for | Anything under ~300 ms, anything you can render on the server | Large regions — it centres a dot in a void | Multi-element regions | Answers that change the page structure |
The column that gets skipped in library docs is the fourth one. react-loading-skeleton and MUI's Skeleton both solve the drawing problem well; neither can tell you whether the region should have had a loading state at all. On a prerendered site, most of them should not.
The audit: where a loading state is legitimate here
This site prerenders almost everything. Product pages, the blog, the category hubs and the marketing pages are all built at deploy time, so by the time any of it reaches a browser the data is already in the HTML — there is no moment to draw a skeleton over. (The route census behind that claim is in React server-side rendering, measured, and what silently opts a page out is in static rendering in the App Router.)
That leaves exactly two kinds of genuinely unknown state on this site:
- Does this viewer own this product? Answered by a client-side lookup against Supabase, because the page itself is static and shared by everyone.
- Has that cross-origin iframe finished loading? Answered by somebody else's server, on somebody else's timeline.
Everything else is server-resolved. Three components, two questions — and the two questions get opposite treatments, which is the interesting part.
Pattern 1: the shaped placeholder
PurchaseCta has to decide between three renderings — a Buy path, an Owned path with a download menu, and a Sign-in-first path — and it cannot know which until the ownership lookup resolves. Flashing "Buy now" at somebody who already owns the template is the failure mode. So the loading branch renders the button it is about to become:
// src/components/molecules/PurchaseCta.tsx
const owned = useOwnedProducts();
const state: State =
owned.status === "loading"
? { kind: "loading" }
: owned.slugs.includes(slug)
? { kind: "owned" }
: { kind: "buy", signedIn: owned.signedIn };
const base = `inline-flex items-center justify-center gap-1.5 rounded-lg px-5 py-2.5 text-sm font-medium duration-200 ${className}`;
if (state.kind === "loading") {
return (
<span
aria-busy="true"
className={`${base} pointer-events-none bg-primary text-white opacity-60`}
>
<Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
Loading…
</span>
);
}
Three things are load-bearing there and none of them is the spinner.
base is the same class string the resolved button uses, so the placeholder occupies the identical box — same padding, same font size, same radius. There is no layout shift when the real state lands, which is the entire benefit a skeleton is usually reached for. Reserving geometry does not require a grey rectangle; it requires using the same geometry.
aria-busy="true" is what tells assistive technology that this region is mid-update. A shimmer div conveys nothing to a screen reader — an animated grey box is invisible to it — so a skeleton without aria-busy (or a role="status" live region) is a purely visual courtesy. Most skeleton tutorials omit this entirely.
pointer-events-none matters because the element still looks like a button. BuyButton.tsx carries the same treatment for the same reason, and its comment states the goal directly:
// src/components/molecules/BuyButton.tsx
// True until the initial session/ownership/checkout-url resolution
// settles — renders a neutral disabled state so the button never flashes
// "Sign in to buy" or "Buy now" before the real state is known.
const [loading, setLoading] = useState(configured);
"Never flashes the wrong answer" is the requirement. A skeleton satisfies it; so does a disabled copy of the control, at a fraction of the maintenance cost, and without a second visual language to keep in sync with the design system.
Pattern 2: the derived-state trick that stops the flash
The other loading state covers an <iframe> pointed at a template's live deployment. Until its load event fires it paints blank white, so PreviewModal covers the frame. The naïve implementation is const [loaded, setLoaded] = useState(false) plus an effect that resets it whenever the URL changes. That version has two bugs, and this codebase avoids both by not storing a boolean at all:
// src/components/molecules/PreviewModal.tsx
// The iframe loads a heavy external demo SPA over the network; until its
// `load` event fires it paints blank white. Track WHICH url finished
// loading (not a bare boolean) so `loaded` is derived state: it flips back
// to false automatically when `url` changes to a different product, with no
// reset effect. Switching the device only changes the iframe width, not
// `src`, so `loadedUrl` stays equal to `url` and the skeleton doesn't flash.
const [loadedUrl, setLoadedUrl] = useState<string | null>(null);
const loaded = loadedUrl === url;
Storing the identity of the thing that finished, rather than the fact that something finished, fixes both failure modes at once:
- Stale-true. With a boolean, opening a second product's preview shows the previous frame as "loaded" for one render, because
loadedis stilltruewhilesrchas already changed. You then need a reset effect, which runs after paint — so the wrong content is visible for a frame no matter how the effect is ordered. - Spurious-false. The modal's device toggle re-lays-out the iframe at 375 / 768 / 1280 px. It does not change
src, so the frame never reloads. A boolean reset keyed on "something changed" flashes the placeholder over a frame that is still perfectly loaded. BecauseloadedUrlis compared againsturl, a width change is invisible to it.
The overlay itself is deliberately a spinner over an opaque panel, not a skeleton:
{!loaded && (
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center gap-3 bg-white dark:bg-gray-900">
<Loader2 className="h-6 w-6 animate-spin text-primary" aria-hidden="true" />
<p className="text-sm text-text-color-secondary dark:text-gray-400">
Loading preview…
</p>
</div>
)}
A skeleton is a promise about layout. We have no idea what the previewed site's layout is — that is the whole point of previewing it — so a fake header-and-three-cards placeholder would be a guess, and a wrong one for most of the catalog. The focus-management side of this component, including the cross-origin iframe bug it has to handle, is covered separately in React modal, zero dependencies.
The negative case: the grid that deliberately has none
TemplateCard also consumes the ownership hook, and it is the component you would expect to want skeletons most — a category page renders dozens of cards, each of which wants to know whether the viewer owns that product. It reads the answer and ignores the loading flag entirely:
// src/components/molecules/TemplateCard.tsx
const owned = useOwnedProducts();
const showDownloads =
product.pricing === "premium" &&
downloadOptions.length > 0 &&
owned.slugs.includes(product.slug);
owned.slugs is empty while loading, so showDownloads is false, so the card renders its normal state and grows a download control if and when the answer says so. For a catalog of 111 products that is the right trade twice over. The answer is "no" for nearly every visitor, so the loading state would be a lie in the common case. And a grid of shimmering placeholders is a worse first impression than a grid of real product cards, one of which gains a button 200 ms later.
The lookup is also cheaper than it looks, which is what makes "just render the default" viable:
// src/lib/useOwnedProducts.ts
let lookupPromise: Promise<Omit<OwnedProducts, "status">> | null = null;
// ...
if (!hasAuthCookie()) return NOBODY;
const supabase = await loadSupabaseClient();
One in-flight lookup per page load is shared by every card on it, and an anonymous visitor never downloads the auth stack at all — the cookie check answers first. That is ~68 KiB gzipped of Supabase client not shipped to a marketing page, measured in Supabase vs Firebase. The general rule: make the answer arrive fast enough and you do not need to decorate the wait.
If you do want a real skeleton
Some regions genuinely qualify — an admin table fetching a page of rows, a chart panel behind an aggregate query, a comment thread. In Tailwind v4 the whole thing is one utility and no dependency:
function TableRowSkeleton() {
return (
<tr aria-hidden="true">
{[40, 24, 32, 16].map((w, i) => (
<td key={i} className="px-4 py-3">
<div
className="h-4 rounded bg-gray-200 motion-safe:animate-pulse dark:bg-gray-700"
style={{ width: `${w}%` }}
/>
</td>
))}
</tr>
);
}
Two details that library docs tend to leave out:
Guard the animation. motion-safe:animate-pulse — not bare animate-pulse — so the pulse stops for anyone who has asked their OS to reduce motion. An audit of this repo found exactly that omission on a live component: the infinite logo marquee in MarqueeRow.tsx had no guard while its neighbour TemplateGallery.tsx did, and the fix shipped alongside the carousel post. A skeleton that pulses forever behind a slow request is the same category of defect.
Hide it from the accessibility tree. aria-hidden="true" on the placeholder, aria-busy="true" on the container that owns it. Otherwise a screen reader announces a run of empty cells as content.
And put the skeleton where the loading actually is. In the App Router that usually means a loading.tsx or a <Suspense fallback> boundary around a server-fetched segment, rather than a useState in a component that had no business fetching. Which of your components can even have a loading state is decided by where the client boundary sits — see Server Components vs Client Components.
Mistakes and how they show up
| Mistake | Symptom | Fix |
|---|---|---|
Boolean loaded with a reset effect | Previous item shows as loaded for one frame after switching | Store which item loaded and derive the boolean by comparison |
| Skeleton geometry drifts from the real layout | Content jumps on arrival; CLS regression nobody attributes to the skeleton | Reuse the real component's class string for the placeholder, or drop the skeleton |
No aria-busy on the updating region | Screen reader users get silence, then content appears with no announcement | aria-busy="true" on the container, aria-hidden="true" on the placeholder |
Bare animate-pulse | Pulsing continues for users with reduced-motion preferences set | motion-safe:animate-pulse |
| Skeleton on a prerendered page | Placeholder flashes before hydration on content the HTML already contained | Render the server data; if it mismatches, that is a hydration bug, not a loading state |
| A skeleton per card in a large grid | Dozens of shimmering boxes for an answer that is the same for most visitors | Render the default state and revise it when the answer arrives |
| Loading state on a sub-300 ms fetch | Flash of placeholder, then content — feels slower than no placeholder | Delay showing it, or omit it |
Frequently asked questions
Is a skeleton actually better than a spinner? For a content-shaped region whose layout you know, yes — it reserves the space, so nothing jumps when the data lands, and it hints at what is coming. For a single control or an opaque region, a spinner inside a correctly-sized box does the same job with none of the maintenance. The measurable difference is layout stability, not perceived speed.
Do I need react-loading-skeleton or a component library?
Not for the drawing. A div with a background colour, a height and motion-safe:animate-pulse is the whole primitive, and the libraries mostly add auto-sizing from surrounding text plus a theming context. Reach for one when you have many differently-shaped skeletons to keep in sync; skip it when you have three.
Should a skeleton match the content exactly? No — matching it exactly is how skeletons rot. Match the bounding box and the rough rhythm (how many rows, roughly how wide), and let the details be approximate. A placeholder that mirrors every element has to be updated every time the component changes, and nothing fails when someone forgets.
Where does the skeleton go in the App Router?
At the Suspense boundary around whatever is actually slow, usually via loading.tsx for a route segment. If you find yourself putting one inside a Client Component that fetches on mount, check first whether the fetch belongs on the server — moving it up is usually the better fix, and it deletes the loading state rather than dressing it.
Templates where these loading states matter
ASoc Scholar Admin is the largest set here — 13 dashboards across 210+ routed pages, which is the scale at which every table and chart panel faces this decision. ASoc Crest Admin ships a full component library alongside 5 dashboards, so its tables, forms and widgets are exactly the content-shaped regions where a real skeleton earns its keep. ASoc Estate Admin is built around property and agent listing tables — long, filterable, and fetched — the clearest case in the catalog for a row-shaped placeholder.
Browse the full set of React admin templates, Next.js admin templates, or Tailwind admin templates.
