Skip to content
WingoUI
ComponentsTemplatesDocsPricingAI agentsBlogChangelog
  1. Home
  2. Blog
  3. Component guides
  4. React Skeleton Loader: Real Shapes, No Layout Shift
Auf Deutsch lesen
  1. Blog
  2. Component guides
  3. React Skeleton Loader: Real Shapes, No Layout Shift
Component guides
Component guides

React Skeleton Loader: Real Shapes, No Layout Shift

SR
Serban Rusu · Founder of Wingo UI
Oct 19, 2026 · 9 min read

On this page

0%
  1. Skeleton vs spinner: which loading state should I use?
  2. How do I build a React skeleton loader with Tailwind?
  3. How do I shape a skeleton like the real content?
    1. Wrap the real content
    2. Use a preset
  4. How do I avoid layout shift when the content loads?
  5. How do I make skeleton loading in React accessible?
  6. When is a spinner the better react loading placeholder?
  7. What should replace a skeleton when the request fails or returns nothing?
  8. What are the common skeleton loading pitfalls?
  9. How do I add these components to my project?
  10. FAQ
    1. Should I use a skeleton or a spinner?
    2. How do I make a skeleton loader with Tailwind CSS?
    3. Do skeleton screens cause layout shift?
    4. Are skeleton loaders accessible?
    5. Is the Wingo UI Skeleton free?

TL;DR

A good React skeleton loader has the same boxes, line heights and corners as the content it stands in for, so the real data drops into place without moving anything. Use it for content whose layout you know, such as feeds, lists, cards and tables, and use a spinner for actions, single small modules and waits of unknown shape. The free Wingo UI Skeleton gives you presets for cards, lists, tables and profiles, plus a wrapping mode that takes the exact size of the real content.

Published Oct 19, 2026

You fetch a list, and for a second the screen is blank. So you add a React skeleton loader: gray bars that shimmer until the data lands. Then the data lands and the page jumps, because the bars were 16px tall and the text is 20px, and the square placeholder became a 16:10 photo. This guide covers when a skeleton is the right loading state, when a spinner is better, how to shape skeletons like the real content, and how to swap them out without layout shift, using the free Wingo UI Skeleton, Spinner and Empty State.

Skeleton vs spinner: which loading state should I use?

Use a skeleton when you know the layout of what is coming, and a spinner when you do not. The Nielsen Norman Group's Skeleton Screens 101 (opens in a new tab) (June 2023, last reviewed September 2026) gives the clearest skeleton vs spinner guidance we have found. It scopes skeletons to full-page loads; we also use them for page-sized regions such as a feed or a table, because the same reasoning applies:

SituationLoading stateWhy
Under about 1 secondNothingAny indicator just flashes
A full page, or a page-sized feed, list or table, under 10 secondsSkeletonThe layout preview tells people what is coming
A single module, such as a video or one dashboard cardSpinnerA skeleton of one box says nothing a spinner does not
An action: saving, signing in, payingSpinner, usually inside the buttonThere is no content to preview
Over 10 seconds, or an upload or importProgress bar with an estimateA looping animation hides how long it will take

The same article says the gray boxes should mimic the structure of the final page, and that skeletons reduce the perceived loading time. Perceived is the key word: the request takes just as long, so if it takes four seconds, fix the request too.

How do I build a React skeleton loader with Tailwind?

The minimal answer to react skeleton loader tailwind is a single div with animate-pulse, a tint, a radius and a fixed size. As of October 2026, shadcn/ui's Skeleton (opens in a new tab) is exactly that: a div with animate-pulse rounded-md bg-accent plus your size classes. It works, and it leaves you to match every block to the real content, announce the wait to screen readers and get the swap right. The Wingo UI Skeleton handles those parts:

bash
npx wingo-ui@latest add skeleton

The base block has three shapes. rect fills the row and is 96px tall until you size it, circle is 40px unless width says otherwise, and line is a 14px text bar. width and height take px numbers or any CSS length, and Tailwind classes work too.

tsx
import { Skeleton, SkeletonText } from "@/components/ui/skeleton";
<Skeleton shape="circle" width={40} />
<Skeleton shape="line" width="40%" />
<SkeletonText lines={2} size="sm" lastLineWidth="70%" />

animation is shimmer (the default), pulse or none. The shimmer is a band that moves on a pseudo-element with transform, not an animated background-position, so the browser can run it on the compositor instead of repainting every frame. speed scales the cycle, and color and highlight take any CSS color. The default fill is a 5% tint of the text color, so it reads in light and dark mode.

Press Reload in the demo below and watch the listing cards: the skeletons and the real cards share one rhythm, so the swap moves nothing.

Press Reload to replay a 1.4 second request: the skeleton cards turn into listings without the row height changing. Switch shape to circle or line to see the wrapping mode
$ npx wingo-ui@latest add skeleton
FreeSkeleton docs

How do I shape a skeleton like the real content?

Two ways: wrap the real content, or use a preset that copies a common layout.

Wrap the real content

Pass the real markup as children and a loading flag. While loading, the children render invisible and inert inside the block, so the block takes their exact size. A rect also copies the corners of what it wraps, such as a rounded photo or a button. When loading turns false, the content fades in where the block was.

tsx
import { Skeleton } from "@/components/ui/skeleton";
import { Button } from "@/components/ui/button";
export function SellerRow({ loading }: { loading: boolean }) {
return (
<div className="flex items-center gap-3">
<Skeleton shape="circle" loading={loading}>
<img src="/emma-carter.jpg" alt="Emma Carter" className="size-10 rounded-full" />
</Skeleton>
<Skeleton shape="line" loading={loading}>
<p className="text-sm font-medium">Emma Carter</p>
</Skeleton>
<Skeleton loading={loading}>
<Button variant="soft" size="sm">Follow</Button>
</Skeleton>
</div>
);
}

The README of react-loading-skeleton (opens in a new tab) makes the same case (as of October 2026): build skeleton states into your components instead of separate skeleton screens, so the styles stay in sync. Its bars default to the height of the font size, and its basic usage imports a CSS file. The wrapping mode here measures the real element instead, so a button or a photo gets its real box too.

Wrapping needs content to measure, so it fits labels, avatars, buttons and text you already have. For items you do not have yet, use a preset.

Use a preset

Four presets cover the layouts that load most often:

  • SkeletonCard: a 16:10 media block (mediaAspect changes it), a title and meta lines, and a footer with a price line and a round action.
  • SkeletonList: 56px rows with a 40px avatar, a title, a subtitle and a button-sized action, with hairlines between rows.
  • SkeletonTable: a header and 48px rows from 768px up, one card per row below that, in pure CSS so the server markup is already right on a phone. That matches the responsive table pattern where rows turn into cards.
  • SkeletonProfile: a cover, an avatar that overlaps it, name and meta lines, and a row of stats.

The line widths inside the presets come from fixed lists, not Math.random(). Random widths look natural, but the server and the client pick different numbers, and React reports a hydration mismatch.

How do I avoid layout shift when the content loads?

Make the skeleton and the content the same size, and never let the reserved space collapse. Google's guide to optimizing CLS (opens in a new tab) sets a good Cumulative Layout Shift at 0.1 or less, recommends reserving space for late content with min-height or aspect-ratio, and warns that removing reserved space shifts the page as much as inserting content does.

Five rules follow:

  1. Same row heights. SkeletonCard uses a 20px title row, 18px meta rows and a 32px footer, with the bar centered in each row. Give your real card leading-5, leading-[18px] and h-8 on the same rows.
  2. Same media box. The skeleton's aspect-[16/10] must match the image's aspect-[16/10], not a fixed height that only matches at one width.
  3. Same grid. Render the skeletons in the same grid classes as the real items.
  4. Same count, roughly. Show about as many skeletons as one screen of real items. Six placeholders that turn into two results is a shift too.
  5. Keep the box when there is nothing. Swap to an empty state in the same container instead of rendering nothing.

Here is the whole pattern in a Next.js App Router page. <Suspense> streams the static heading at once and the listings when they are ready, as the Next.js data fetching docs (opens in a new tab) describe. A loading.tsx file next to the page does the same for the entire route segment.

tsx
// app/listings/page.tsx
import { Suspense } from "react";
import { SkeletonCard } from "@/components/ui/skeleton";
import { EmptyState } from "@/components/ui/empty-state";
import { getListings } from "@/lib/listings";
const GRID = "mt-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-3";
export default function ListingsPage() {
return (
<main className="mx-auto max-w-5xl px-4 py-8">
<h1 className="text-2xl font-semibold tracking-tight">Apartments in New York</h1>
<Suspense fallback={<ListingsSkeleton />}>
<Listings />
</Suspense>
</main>
);
}
function ListingsSkeleton() {
return (
<div className={GRID}>
{Array.from({ length: 6 }, (_, i) => (
// one announcement for the grid, not six
<SkeletonCard key={i} label="Loading listings" aria-hidden={i > 0 || undefined} />
))}
</div>
);
}
async function Listings() {
const listings = await getListings();
if (listings.length === 0) {
return (
<EmptyState
preset="no-results"
variant="surface"
className="mt-6 min-h-80"
title="No apartments match yet"
description="Try a larger area or remove a filter."
/>
);
}
return (
<div className={GRID}>
{listings.map((listing) => (
<article key={listing.id} className="flex flex-col gap-3">
<img src={listing.photo} alt="" className="aspect-[16/10] w-full rounded-[10px] object-cover" />
<div className="flex flex-col gap-1">
<h2 className="truncate text-[15px] font-semibold leading-5">{listing.title}</h2>
<p className="truncate text-[13px] leading-[18px] text-muted-foreground">{listing.area}</p>
</div>
<div className="flex h-8 items-center">
<span className="text-lg font-semibold tabular-nums">{listing.price}</span>
</div>
</article>
))}
</div>
);
}

The Skeleton and Empty State are client components; a server component can render them because the props here are plain data. The image corners are rounded-[10px], the preset's default radius="md".

How do I make skeleton loading in React accessible?

Announce the wait once and keep the gray blocks silent. Every block is aria-hidden, and each preset is a role="status" region with aria-busy and one hidden label, "Loading" by default. Set label to something specific ("Loading invoices"). Extra props land on the preset root after its own attributes, which is why aria-hidden on the extra cards in the grid above silences them.

Wrapped content is inert while it loads, so Tab skips a button that is not ready yet. Under prefers-reduced-motion the shimmer is hidden and the pulse stops, so every block stays still. NN/g notes that animated skeletons can be distracting, so this matters. The prefers-reduced-motion guide covers the hook that keeps that switch hydration-safe.

When is a spinner the better react loading placeholder?

When there is no layout to preview. A spinner is the right react loading placeholder for a form submit, a sign in, a payment, a pull to refresh, or a single panel whose content changes shape. The Wingo UI Spinner has six styles (ring, dots, bars, pulse, orbit and the iOS-like spokes), sm, md and lg sizes of 16, 24 and 40px, and a label that screen readers hear once as a polite status.

Its most useful prop is delay. With delay={300} nothing renders for the first 300 milliseconds, so a fast response never flashes a spinner, and a slow one fades it in.

tsx
import { Spinner } from "@/components/ui/spinner";
export function SearchPending() {
return (
<div className="flex min-h-52 flex-col items-center justify-center gap-3 text-muted-foreground">
<Spinner variant="spokes" size="lg" delay={300} label="Searching apartments" />
<p className="text-sm">Searching apartments in New York…</p>
</div>
);
}

The min-h-52 reserves space, as a skeleton would. For actions, skip the separate spinner and use the Button loading prop. The button keeps its width while it shows a spinner, and loadingText="Saving" adds a label next to it.

The delay is set to 300ms. Press Reload: the spinner stays hidden at first, then fades in once the wait is long enough to notice
$ npx wingo-ui@latest add spinner
FreeSpinner docs

What should replace a skeleton when the request fails or returns nothing?

An empty state in the same box. The Empty State has presets for empty, no-results, error, first-run, no-permission and offline, each with its own icon, tone and copy. On the error or offline preset, onRetry adds a Try again button that spins until the promise you return settles. Since onRetry is a function, render that one from a client component.

What are the common skeleton loading pitfalls?

Three mistakes in skeleton loading React code only show once real data and real networks arrive.

  • One shade for everything. A skeleton on a card of the same gray disappears. The default tint is relative to the text color, so it shows on any surface; on a brand-colored banner, pass a white color and highlight.
  • Skeletons for a 200ms request. If most responses come back fast, the skeleton only flashes. Show cached data, or wrap the wait in a spinner with a delay.
  • A skeleton that never ends. A request that fails must replace the skeleton with an error and a retry, or people wait forever.

How do I add these components to my project?

Run npx wingo-ui@latest add skeleton spinner empty-state. The CLI copies the source into your project with the helpers and components they import (the Empty State brings the Button) and installs the npm packages they use (motion, lucide-react and @radix-ui/react-slot). All three are free. If every skeleton in your app should pulse instead of shimmer, set it once in <UIProvider defaults> instead of passing the prop each time. The base block reads the skeleton key, and SkeletonText and each preset read their own (skeleton-text, skeleton-card, skeleton-list, skeleton-table, skeleton-profile) because they pass animation to their blocks explicitly. So defaults={{ skeleton: { animation: "pulse" }, "skeleton-card": { animation: "pulse" } }} covers bare blocks and cards. More single-component deep dives are in the component guides category, and the React form components guide applies the same accessibility and mobile rules to every form control.

Components in this post

  • Skeleton

    A React skeleton loader shaped like the content on its way: a shimmering block, text lines and presets for cards, lists and tables.

    Free
  • Spinner

    A React loading spinner for waits without a known shape: six styles, from a quiet ring to the iOS spokes, in any size, tone or speed.

    Free
  • Empty State

    A React empty state for lists, tables, searches and pages with nothing to show yet: an icon tile, a title, a description and actions.

    Free

FAQ

Should I use a skeleton or a spinner?

Use a skeleton when you know the layout of the content that is loading, such as a feed, a list or a full page. Use a spinner for actions like saving or signing in, for a single small module, and when you cannot predict what will appear. Past about 10 seconds, show a progress bar instead.

How do I make a skeleton loader with Tailwind CSS?

The simplest version is a div with animate-pulse, a tint such as bg-foreground/5, a radius and a fixed height and width. Give every block the height of the text line or image it replaces, or the page jumps when the data arrives.

Do skeleton screens cause layout shift?

Only when they are a different size than the content that replaces them. Match line heights, gaps, image aspect ratios and the grid, and keep the reserved space even when the request returns nothing.

Are skeleton loaders accessible?

They can be. Hide the gray blocks from screen readers, announce one short label such as Loading listings, keep focus out of content that is not ready, and stop the shimmer when the user asks for reduced motion.

Is the Wingo UI Skeleton free?

Yes. The Skeleton, the Spinner and the Empty State are all free components. Install them with npx wingo-ui@latest add skeleton spinner empty-state.

  • Skeleton
  • Loading states
  • Core Web Vitals
  • Accessibility
  • React

Share

SR

About the author

Serban Rusu

Founder of Wingo UI

Serban Rusu founded Wingo UI. He builds its component library, CLI and MCP server, and writes about React interfaces for phones and AI coding agents.

More from Serban
Keep reading

Related posts

All posts
Component guides

React Empty State Component: First Use, No Results, Errors

Build a React empty state component for first use, no results and errors, with copy that says what happened and one action that moves people forward.

SRSerban Rusu·Oct 19, 2026·8 min read
Build it: screens and apps

React 404 Page: 5 Error Screens for the Next.js App Router

Build a React 404 page that helps people recover, plus 500, maintenance, offline and no access screens, wired into the Next.js App Router with the right status.

SRSerban Rusu·Oct 16, 2026·11 min read
Component guides

Embla Carousel React: Accessible Slides, No Layout Shift

Build an embla carousel react component for photos, listings, testimonials and onboarding, with keyboard controls, pausable autoplay and no layout shift.

SRSerban Rusu·Oct 19, 2026·9 min read
Newsletter

Get new posts by email

New guides, tutorials and comparisons from the Wingo UI blog, sent when they are published.

No spam. Unsubscribe at any time.

WingoUI

Animated, configurable, mobile-first React components and blocks. Copy the source, make it yours, and let your coding agent build with it.

Product

  • Components
  • Templates
  • Pricing
  • AI agents
  • Theme generator

Resources

  • Docs
  • Blog
  • Changelog
  • Changelog RSS
  • llms.txt

Legal

  • Terms
  • Privacy
  • Refunds
  • License
  • Imprint

Component categories

  • Buttons & Actions
  • Inputs
  • Forms
  • Navigation
  • Overlays
  • Feedback
  • Data Display
  • Tables & Lists
  • Charts & Stats
  • Layout
  • Media
  • AI Kit
  • Text & Effects
  • Mobile
  • Commerce
  • Marketing Sections
  • Blocks
  • Hooks & Utilities

© 2026 Wingo UI

Wingo UI