# React Skeleton Loader: Real Shapes, No Layout Shift

> Build a React skeleton loader shaped like the real content, pick a spinner when a skeleton is wrong, and swap in the data without any layout shift.

- Author: [Serban Rusu](https://wingo-ui.com/blog/authors/serban), Founder of Wingo UI
- Published: Oct 19, 2026
- Category: [Component guides](https://wingo-ui.com/blog/category/components)
- Reading time: 9 min
- Canonical: https://wingo-ui.com/blog/react-skeleton-loader

## 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.

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](https://wingo-ui.com/components/skeleton), [Spinner](https://wingo-ui.com/components/spinner) and [Empty State](https://wingo-ui.com/components/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](https://www.nngroup.com/articles/skeleton-screens/) (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:

| Situation | Loading state | Why |
|---|---|---|
| Under about 1 second | Nothing | Any indicator just flashes |
| A full page, or a page-sized feed, list or table, under 10 seconds | Skeleton | The layout preview tells people what is coming |
| A single module, such as a video or one dashboard card | Spinner | A skeleton of one box says nothing a spinner does not |
| An action: saving, signing in, paying | Spinner, usually inside the button | There is no content to preview |
| Over 10 seconds, or an upload or import | Progress bar with an estimate | A 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](https://ui.shadcn.com/docs/components/skeleton) 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.

> Live demo (Skeleton): 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. Try it at [Skeleton](https://wingo-ui.com/components/skeleton) and install it with `npx wingo-ui@latest add skeleton`.

## 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](https://github.com/dvtng/react-loading-skeleton) 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](https://wingo-ui.com/blog/responsive-table-mobile-cards) 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](https://web.dev/articles/optimize-cls) 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](https://nextjs.org/docs/app/getting-started/fetching-data) 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](https://wingo-ui.com/blog/prefers-reduced-motion-react) 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](https://wingo-ui.com/components/button) `loading` prop. The button keeps its width while it shows a spinner, and `loadingText="Saving"` adds a label next to it.

> Live demo (Spinner): 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. Try it at [Spinner](https://wingo-ui.com/components/spinner) and install it with `npx wingo-ui@latest add spinner`.

## 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](https://wingo-ui.com/blog/category/components), and the [React form components guide](https://wingo-ui.com/blog/react-form-components-guide) applies the same accessibility and mobile rules to every form control.

## Components in this post

- [Skeleton](https://wingo-ui.com/components/skeleton) (Free): A React skeleton loader shaped like the content on its way: a shimmering block, text lines and presets for cards, lists and tables. Install: `npx wingo-ui@latest add skeleton`
- [Spinner](https://wingo-ui.com/components/spinner) (Free): 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. Install: `npx wingo-ui@latest add spinner`
- [Empty State](https://wingo-ui.com/components/empty-state) (Free): A React empty state for lists, tables, searches and pages with nothing to show yet: an icon tile, a title, a description and actions. Install: `npx wingo-ui@latest add empty-state`

## 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.

---

Source: https://wingo-ui.com/blog/react-skeleton-loader
