Guides · August 22, 2023
Empty States, Loading Skeletons & Perceived Speed
Skeleton screens and well-designed empty states make a React storefront feel fast even when data is still loading. Here is how to build both correctly — shape-matched skeletons, staged empty states, and layout-stable transitions that avoid cumulative layout shift.
By Polo Themes
Perceived speed on an ecommerce storefront comes from three things working together: skeleton screens that match the final layout pixel-for-pixel, empty states that read as intentional design rather than a broken page, and transitions between the two that never shift content around. Get the shapes and timing right and a product grid that takes 400ms to hydrate feels instant. Get them wrong — mismatched skeleton dimensions, a bare "No results" div, content that jumps as real data arrives — and even a fast page feels sluggish or broken. This guide covers how to build both patterns properly in React, why spinners are almost always the wrong default, and how to keep Cumulative Layout Shift near zero while data streams in.
Why Skeletons Beat Spinners for Commerce UI
A spinner tells the shopper "something is happening, but I have no idea what." A skeleton screen tells them "a product grid is arriving, and here is roughly where every element will land." That second message is far more useful, because it lets the brain start parsing the page layout before the data exists. This is the same principle behind why airport passengers feel a 20-minute walk to baggage claim as shorter than a 12-minute wait standing at the carousel — a filled wait feels shorter than an empty one, and a wait with visible structure feels more filled than a wait with a spinning icon and nothing else.
Spinners also fail specifically on commerce pages because they hide layout information the shopper needs to orient. A product listing page has a predictable shape — a grid of cards, each with an image, a title, a price, maybe a rating. If you replace all of that with a single centered spinner, the shopper loses their place: they don't know if they're looking at four products or forty, whether it's a grid or a list, or how far down the page results will run. A skeleton grid answers all of that immediately, before a single byte of real product data has arrived.
The one place a spinner is still the right call is a genuinely unpredictable, short, blocking action — submitting a form, confirming a payment, a button's own busy state. Anywhere the final content has a knowable shape — product grids, cart line items, order history, search results — skeletons are the better default.
Rule One: The Skeleton Must Match the Real Layout
The single most common skeleton mistake is building it as a generic loading component — a few gray boxes stacked vertically — instead of a shape that mirrors the actual rendered component. If your product card is a 3:4 image, a two-line title, a price, and a rating row, the skeleton needs those same four regions in the same proportions and the same spacing. When the skeleton and the real card don't line up, the swap between them causes a visible jump even if no dimension officially changed, because the eye had already locked onto the skeleton's rhythm.
The practical pattern in React is to build the skeleton as a sibling variant of the real component, sharing the same outer wrapper, spacing tokens, and aspect ratios — not a separate, loosely similar placeholder component maintained independently. A ProductCardSkeleton should import the same grid/card container styles as ProductCard, and swap only the inner content: image becomes a shimmering block at the exact same aspect ratio, title becomes one or two shimmering lines sized to the average title length for that catalog, price becomes a short shimmering block roughly the width of a typical price string. Anything less precise reads as a placeholder; anything this precise reads as the page settling into itself.
- Match the container, not just the content — use the same padding, gap, and border-radius tokens as the live card so the skeleton's card boundary lines up exactly.
- Match the aspect ratio of media — if the real image is 3:4, the skeleton block must be 3:4, reserved via CSS aspect-ratio, not a fixed pixel height that only happens to look right on one viewport.
- Vary skeleton line widths slightly — three identical-width gray bars in a row reads as obviously synthetic; slightly varied widths (80%, 60%, 40%) read as text that just hasn't loaded yet.
- Skeleton count should match expected count — render the same number of skeleton cards as the page size you're about to fetch (12, 24, whatever your grid uses), not an arbitrary 3 or 4.
Building a Skeleton Component in React
A skeleton component in React is almost always simpler than the real component it stands in for, which is exactly why it's tempting to under-invest in it. Resist that — treat it as a first-class component with its own file, its own props for count and layout variant, and its own tests for the shimmer accessibility attributes below. The shimmer effect itself is best implemented as a single reusable CSS animation (a gradient sweep on a background-position keyframe, or an opacity pulse for users with reduced-motion preferences) applied via a shared Skeleton primitive — a bare div with a fixed width/height/border-radius and the shimmer class — that every specific skeleton (product card, cart line, review) composes from.
That composition matters for maintenance: when the design system's shimmer color, speed, or easing changes, it should change once, in the primitive, not in every hand-rolled skeleton scattered across the codebase. It also matters for consistency — a checkout page where the cart skeleton shimmers at a different speed than the recommended-products skeleton below it looks like two different developers built two different loading systems, which, visually, undermines the sense of a coherent product.
Respect prefers-reduced-motion unconditionally. A shimmering gradient sweep is a subtle animation to most users and a genuine discomfort trigger for some. The shimmer primitive should check the media query (via CSS, not JavaScript, so it doesn't need to know about the user's OS settings at render time) and fall back to a static, slightly-lower-contrast fill with no animation when the preference is set. This is a two-line CSS media query and it is skipped constantly.
Accessibility: Skeletons Are Not Silent
A skeleton screen is a visual trick for sighted users, and it means nothing to a screen reader unless it's wired up correctly. Wrap the loading region in a container with aria-busy="true" while skeletons are showing, and flip it to false the moment real content mounts. Give the region a role="status" or an associated aria-live="polite" announcement only at the moment loading actually completes — for example "24 products loaded" — rather than announcing anything during the loading state itself, which would otherwise spam a screen reader with the presence of every individual shimmering placeholder. Do not put aria-hidden="true" on the skeleton wrapper carelessly — that hides it from assistive tech entirely, which is usually fine since there's nothing meaningful to read, but double-check that the aria-busy region itself is still discoverable so the eventual "content loaded" announcement fires.
Avoiding Cumulative Layout Shift During the Swap
Cumulative Layout Shift (CLS) is a Core Web Vital, and skeleton-to-content transitions are one of the most common places it quietly gets worse, not better. The failure mode is subtle: the skeleton grid renders at one height, then real data arrives with a slightly different content shape — a longer product title wraps to a third line, a badge appears that wasn't accounted for, an image with a different natural aspect ratio loads — and the page reflows after the shopper has already started scrolling or reading.
- Reserve space with aspect-ratio, not min-height guesses. Set the image container's CSS aspect-ratio to the same value the real image will use, so the box never changes size once you swap the skeleton block for the actual img element.
- Cap title lines with a fixed line-clamp, and size the skeleton's title placeholder to occupy exactly that many lines at that font size — two lines of skeleton text under a two-line line-clamp title, not three lines of skeleton collapsing to two lines of real content.
- Never let a badge, discount tag, or rating appear only in the loaded state. If some products have a "Sale" badge and others don't, reserve the badge's slot in the skeleton (even empty) so its later appearance doesn't push the price down by a few pixels.
- Swap in place, don't remount the parent. Keep the same outer grid and card wrapper mounted throughout, and only replace the innermost content nodes — remounting the whole card (a new key, a conditional that swaps entire subtrees) is what typically triggers an actual layout recalculation and a visible pop.
Wiring Skeletons to Real Data Fetching
In a modern React data-fetching setup — React Query, SWR, or React 19's built-in use() hook with Suspense — the skeleton's natural home is a Suspense fallback around the component that reads the async data. This keeps the loading UI declarative: the component that renders products doesn't need an internal isLoading branch at all if it's wrapped in a Suspense boundary whose fallback is the matching skeleton grid. The discipline that matters here is boundary granularity — one Suspense boundary per independently-loading region (the product grid, the filter sidebar, the recommendations carousel), not one giant boundary around the whole page. A single page-level boundary means a slow "customers also bought" query blocks the skeleton-to-content swap for the entire page, including the primary grid that loaded in 200ms. Scope boundaries to match what a shopper perceives as one unit.
For data fetched outside Suspense — a manual useEffect plus state, or a mutation-driven refetch after adding to cart — the same discipline applies with an explicit boolean: track loading state per region, not one global "isLoading" flag for the whole page, and render that region's skeleton only for that region.
Designing Empty States That Don't Read as Broken
An empty state is what a skeleton resolves to when there genuinely is nothing to show — zero search results, an empty cart, a wishlist with nothing saved, a filtered collection with no matches. The default failure mode is a bare line of text sitting in an otherwise-unstyled area, which reads to a shopper as "the page is broken" rather than "there is nothing here yet." A good empty state needs the same layout discipline as a skeleton: it should occupy roughly the space the content grid would have occupied, not collapse to a single centered sentence, so the page doesn't visually lurch when the empty state itself later resolves into real content (a shopper adds a filter that returns results, or adds an item to an empty cart).
Every commerce empty state should answer three questions in order: what happened, why it happened (if useful), and what to do next. "No products match your filters" states the fact. Naming the active filters that caused it — "no results for Blue under $50" — gives the shopper enough information to self-diagnose. And a clear action — a Clear filters button, a link back to the full collection, a suggested related category — turns a dead end back into forward motion. An empty cart benefits from the same structure: state it plainly, then point at a Shopify theme catalog or featured collection rather than leaving a shopper looking at a blank cart icon with no next step.
First-load empty states need to be distinguished from zero-result empty states
These are two different situations that are easy to conflate in code and should never be conflated in UI. A brand-new account with an empty order history is not the same story as a search that legitimately returned nothing, and a wishlist a shopper has simply never used is not the same as a saved-items feature that failed to load. Write separate copy and, where it's cheap to do so, separate illustration or iconography for each — an "orders will show up here after your first purchase" message with a link to browse, versus a "try removing a filter" message with a clear-filters action. Collapsing both into one generic "Nothing here" component saves a small amount of code and costs real clarity for the shopper.
A Practical Checklist for a Product Listing Page
Pulling the above together, here is the sequence a well-built PLP moves through, and what each stage should look like in React terms.
- Initial render: a Suspense boundary (or an explicit isLoading flag) wraps the grid region only, rendering N skeleton cards matching the page size, with aria-busy set on the region.
- Skeleton cards: each mirrors the real ProductCard's container, image aspect ratio, title line count, and price width, using a shared shimmer primitive that respects prefers-reduced-motion.
- Data arrives with results: the boundary resolves, skeleton nodes are swapped for real content in place (same wrapper, same grid), aria-busy flips to false, and a polite live-region announcement states the result count once.
- Data arrives empty: instead of a real product grid, a full-width empty state renders in the same region, stating the zero-result reason, naming active filters, and offering a clear-filters or browse-all action.
- Pagination/infinite scroll: only the newly-appended page of cards gets its own skeleton batch; already-rendered cards above it are never re-skeletonized or remounted.
This pattern generalizes cleanly beyond the PLP — a cart drawer, an order history table, a recently-viewed carousel, and a product review list all follow the same skeleton-then-content-or-empty shape, just with different card geometry. If you're building this from scratch inside a Next.js storefront, it's worth designing the skeleton and empty-state variants for every reusable content region at the same time you design the loaded state, in the same Figma file, rather than treating loading and empty states as an afterthought bolted on once the "real" UI ships — our Figma UI kits follow that approach, with loading and empty variants included alongside the primary layouts precisely because they're part of the same design decision, not a separate one.
Frequently Asked Questions
Do skeleton screens actually improve Core Web Vitals, or just perceived speed?
Both, if built correctly. Perceived speed is the primary win — skeletons don't make data arrive faster, they make the wait feel shorter. But a well-built skeleton that reserves exact final dimensions also directly improves Cumulative Layout Shift, because it prevents the layout jump that happens when content pops in at a different size than whatever was showing before it. A poorly built skeleton — one with mismatched dimensions — can make CLS worse than no skeleton at all, so the dimension-matching discipline isn't optional polish, it's the mechanism that makes the metric improve.
Should every async component have its own skeleton, or is that overkill?
Match the skeleton granularity to what loads independently and what a shopper perceives as a distinct region. A product grid, a filter sidebar, and a recommendations carousel that each fetch separately should each have their own skeleton and their own Suspense boundary. Two pieces of UI that always load and resolve together — say, a product's title and price coming from the same query — don't need separate skeletons; one combined placeholder for that unit is simpler and just as effective.
Is a spinner ever acceptable on a storefront?
Yes, for short, unpredictable, blocking actions where the resulting content has no knowable shape in advance — a form submission, a payment confirmation, a button's own inline busy state. Anywhere the eventual content has a predictable layout, a skeleton is almost always the better choice, because it communicates structure a spinner cannot.
How do I test that my skeleton actually matches my real component?
Render both side by side in Storybook or a design-review environment and overlay them, or take screenshots at the same viewport and diff them visually — the outer card boundary, image box, and text block positions should be identical between the two, with only the shimmer versus real content differing inside. If you maintain a visual regression suite, add a snapshot of the loading state alongside the loaded state for every reusable content component, so a future refactor that changes card padding or image aspect ratio gets caught in both places at once.