# Skeleton Loader With Shimmer in Next.js and Tailwind

> Export a skeleton from loading.tsx and Next.js shows it while the page streams in. Wrap each slow panel in its own Suspense so one slow query doesn't hold back the rest. Make every block the same size as the real content so nothing jumps, hide the blocks with aria-hidden, announce loading once, and turn the shimmer off with motion-reduce.

- URL: https://qodesign.dev/blog/skeleton-loader-shimmer-nextjs-tailwind
- Author: Teodor Avadani (https://qodesign.dev/blog/author/teodor-avadani)
- Published: 2026-10-21
- Updated: 2026-10-21
- Category: Animation
- Publisher: qodesign (https://qodesign.dev), a shop of animated React + Tailwind CSS components for Next.js, each sold with its full source code and an AI prompt.

A skeleton loader is a grey block the size of the content that's coming, with a soft light sweeping across it. In Next.js App Router you get one for free at the route level by exporting it from `loading.tsx`, and you can go finer by wrapping each slow panel in `<Suspense>` with its own skeleton as the fallback. The shimmer is one keyframe in Tailwind v4 and a pseudo-element. The part that matters more than the shine is that every skeleton has the same size as the thing it stands in for.

I make [qodesign](/), a set of animated React + TypeScript + Tailwind components. Our free [Orbit Dashboard](/components/orbit-dashboard) has KPI cards, a latency chart and an events table, so it's the layout I'll build skeletons for here. The code below was written for this post and compiled with TypeScript in strict mode against React 19 and Next.js 15. It needs no library.

## Pulse or shimmer?

Tailwind ships `animate-pulse`, which fades the block's opacity in and out on a 2 second loop. It's fine, and it's one class. A shimmer is a gradient that travels across the block. It reads as "loading" a bit faster on dark dashboards, where a pulse between two dark greys is hard to see. Pick one and use it everywhere. Mixing them on one screen looks like two different apps.

## The shimmer keyframes in Tailwind v4

Tailwind v4 lets you define an animation and its keyframes inside `@theme` in your CSS. Add this to `app/globals.css` and you get an `animate-shimmer` utility:

```css
@import "tailwindcss";

@theme {
  --animate-shimmer: shimmer 1.5s ease-in-out infinite;

  @keyframes shimmer {
    from {
      transform: translateX(-100%);
    }
    to {
      transform: translateX(100%);
    }
  }
}
```

The keyframes set both ends on purpose. In v4, `-translate-x-full` sets the CSS `translate` property, and the animation moves `transform`, so mixing the two adds the offsets together. Putting both positions in the keyframes keeps it predictable. On Tailwind v3 the same animation goes in `theme.extend.keyframes` and `theme.extend.animation` in `tailwind.config.js`, and the gradient class is `bg-gradient-to-r` where v4 uses `bg-linear-to-r`.

## A Skeleton component

```tsx
// components/Skeleton.tsx
import type { ComponentPropsWithoutRef } from "react";

export function Skeleton({ className = "", ...props }: ComponentPropsWithoutRef<"div">) {
  return (
    <div
      aria-hidden="true"
      {...props}
      className={`relative overflow-hidden rounded-md bg-zinc-200 dark:bg-zinc-800 after:absolute after:inset-0 after:animate-shimmer after:bg-linear-to-r after:from-transparent after:via-white/50 dark:after:via-white/10 after:to-transparent motion-reduce:after:hidden ${className}`}
    />
  );
}
```

The light is an `::after` layer that slides across with `overflow-hidden` clipping it. It moves with `transform`, so it doesn't make the browser redo layout on every frame. `aria-hidden` keeps screen readers from reading a pile of empty boxes, and `motion-reduce:after:hidden` turns the sweep off for people who asked their system for less motion. A still grey block is a perfectly good loading state.

There's no server-only or client-only code in it, so it works in Server Components and in `loading.tsx` without `"use client"`. Because it takes every normal `div` prop, you size each one with classes and pass `style` when you need it.

## Make the skeleton match the real layout

The most common mistake is a skeleton that doesn't match the page. A spinner in the middle of the screen, or three generic bars, means everything jumps when the data lands. Build the skeleton out of the same grid, gaps and heights as the real panels:

```tsx
// components/DashboardSkeleton.tsx
import { Skeleton } from "@/components/Skeleton";

export function KpiSkeleton() {
  return (
    <div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
      {Array.from({ length: 4 }, (_, i) => (
        <div key={i} className="rounded-2xl border border-zinc-800 p-4">
          <Skeleton className="h-3 w-20" />
          <Skeleton className="mt-3 h-8 w-24" />
          <Skeleton className="mt-4 h-10 w-full" />
        </div>
      ))}
    </div>
  );
}

export function ChartSkeleton() {
  return <Skeleton className="h-72 w-full rounded-2xl" />;
}

export function TableSkeleton({ rows = 6 }: { rows?: number }) {
  return (
    <div className="divide-y divide-zinc-800 rounded-2xl border border-zinc-800">
      {Array.from({ length: rows }, (_, i) => (
        <div key={i} className="flex h-12 items-center gap-4 px-4">
          <Skeleton className="h-3 w-16" />
          <Skeleton className="h-3 flex-1" style={{ maxWidth: `${45 + ((i * 37) % 35)}%` }} />
          <Skeleton className="ml-auto h-5 w-14 rounded-full" />
        </div>
      ))}
    </div>
  );
}
```

Each KPI card has the label, the big number and the sparkline in the same spots as the real card. The chart placeholder is the same `h-72` as the chart. The table rows are the real row height, `h-12`.

The table rows get different widths so they look like text. The widths come from the row index, not `Math.random()`. A random width renders one number on the server and another in the browser, and React reports a hydration mismatch. We wrote up that error in [fixing hydration errors from dates and random values](/blog/fix-hydration-error-dates-random-nextjs).

## Route level: loading.tsx

Put a `loading.tsx` next to `page.tsx` and Next.js wraps the page in a Suspense boundary with this as the fallback. The [Next.js docs for loading.js](https://nextjs.org/docs/app/api-reference/file-conventions/loading) say the fallback is prefetched, so navigation shows it straight away, and shared layouts stay interactive while the segment loads.

```tsx
// app/dashboard/loading.tsx
import { ChartSkeleton, KpiSkeleton, TableSkeleton } from "@/components/DashboardSkeleton";

export default function Loading() {
  return (
    <div role="status" className="space-y-4 p-6">
      <span className="sr-only">Loading dashboard</span>
      <KpiSkeleton />
      <ChartSkeleton />
      <TableSkeleton />
    </div>
  );
}
```

The wrapper has `role="status"` and one line of screen reader text. That way someone using a screen reader hears "Loading dashboard" once, while the blocks themselves stay hidden.

There's a second reason to add it. According to the [Next.js guide to linking and navigating](https://nextjs.org/docs/app/getting-started/linking-and-navigating), a dynamic route is either not prefetched at all or only partly prefetched when it has a `loading.tsx`. Without one, a click on a link to a dynamic page can sit there with nothing happening until the server answers.

## Panel level: one Suspense per panel

`loading.tsx` swaps the whole page at once, so the slowest query decides when anyone sees anything. If your panels load from different sources, give each one its own boundary:

```tsx
// app/dashboard/page.tsx
import { Suspense } from "react";
import { ChartSkeleton, KpiSkeleton, TableSkeleton } from "@/components/DashboardSkeleton";
import { Kpis } from "@/components/Kpis";
import { LatencyChart } from "@/components/LatencyChart";
import { EventsTable } from "@/components/EventsTable";

export default function DashboardPage() {
  return (
    <main className="space-y-4 p-6">
      <Suspense fallback={<KpiSkeleton />}>
        <Kpis />
      </Suspense>
      <Suspense fallback={<ChartSkeleton />}>
        <LatencyChart />
      </Suspense>
      <Suspense fallback={<TableSkeleton />}>
        <EventsTable />
      </Suspense>
    </main>
  );
}
```

Each panel is an async Server Component that fetches its own data:

```tsx
// components/Kpis.tsx
type Kpi = { label: string; value: string };

async function getKpis(): Promise<Kpi[]> {
  const res = await fetch("https://api.example.com/kpis", { cache: "no-store" });
  if (!res.ok) throw new Error("Failed to load KPIs");
  return res.json();
}

export async function Kpis() {
  const kpis = await getKpis();
  return (
    <div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
      {kpis.map((k) => (
        <div key={k.label} className="rounded-2xl border border-zinc-800 p-4">
          <p className="text-xs text-zinc-400">{k.label}</p>
          <p className="mt-2 text-3xl font-semibold tabular-nums">{k.value}</p>
        </div>
      ))}
    </div>
  );
}
```

`LatencyChart` and `EventsTable` are built the same way. Now the KPIs can appear while the events table is still waiting, and each skeleton is replaced by the panel it was shaped like. React's [Suspense reference](https://react.dev/reference/react/Suspense) covers the finer cases, like revealing panels together or nesting boundaries.

## Data fetched in the browser

Some data only exists on the client, like a list you refetch on a button press. The skeleton works there too. Show it while the state is empty, and mark the region busy:

```tsx
// components/ClientEvents.tsx
"use client";

import { useEffect, useState } from "react";
import { TableSkeleton } from "@/components/DashboardSkeleton";

type Event = { id: string; message: string };

export function ClientEvents() {
  const [events, setEvents] = useState<Event[] | null>(null);

  useEffect(() => {
    let alive = true;
    fetch("/api/events")
      .then((r) => r.json())
      .then((data: Event[]) => {
        if (alive) setEvents(data);
      });
    return () => {
      alive = false;
    };
  }, []);

  return (
    <section aria-busy={events === null}>
      {events === null ? (
        <TableSkeleton />
      ) : (
        <ul className="divide-y divide-zinc-800">
          {events.map((e) => (
            <li key={e.id} className="px-4 py-3">{e.message}</li>
          ))}
        </ul>
      )}
    </section>
  );
}
```

[`aria-busy`](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-busy) tells assistive technology that a region is being updated, so it can wait until the content is ready. The `alive` flag stops a late answer from setting state after the component has unmounted.

## Screen readers and reduced motion

Two rules cover most of it:

- Hide the skeleton blocks with `aria-hidden` and announce loading once, with a `role="status"` line or `aria-busy` on the region.
- Respect [`prefers-reduced-motion`](https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-reduced-motion). Tailwind's `motion-reduce:` variant does it in a class. We cover the wider topic in [prefers-reduced-motion in React](/blog/prefers-reduced-motion-react).

## Five bugs in AI-generated skeletons

When you ask Cursor, v0 or Lovable for "a loading state", these come back often:

1. A centred spinner in `loading.tsx` instead of a skeleton of the page, so the whole layout jumps when the page arrives.
2. Skeleton heights that don't match the real panels. The page shifts by the difference, which shows up as layout shift.
3. `Math.random()` widths, which cause a hydration mismatch.
4. A shimmer that runs for everyone, ignoring reduced motion.
5. One Suspense boundary around the whole page, so a single slow query holds back every panel.

A prompt that avoids most of them: "Build a loading.tsx skeleton that uses the same grid, gaps and heights as page.tsx, hides the blocks with aria-hidden, announces loading once with role=status, turns the shimmer off with motion-reduce, and wraps each data panel in its own Suspense."

## Trying it on a real dashboard

The [Orbit Dashboard](/components/orbit-dashboard) is free under the MIT licence, with no email needed. It's a dark ops dashboard: six KPI cards that count up and draw sparklines, a live service map, a p50/p95/p99 latency chart and a filterable events table. Its panels are separate components, so each one can sit inside its own Suspense with a skeleton shaped like it. There's a short guide on our [observability dashboard page](/react/observability-dashboard), and the folder has a PROMPT.md you can paste into your AI editor. If you'd like one of our paid $3 pieces as well, [pick one free here](/free).

## FAQ

### How do I add a skeleton loader in Next.js App Router?

Create `loading.tsx` in the route folder and return a skeleton of the page. Next.js wraps `page.tsx` in a Suspense boundary and shows your skeleton while the page streams in. For finer control, wrap each slow Server Component in `<Suspense fallback={...}>` with a skeleton shaped like that component.

### Should I use animate-pulse or a shimmer?

Either works. `animate-pulse` is built into Tailwind and needs no setup. A shimmer takes one keyframe in `@theme` and is easier to see on dark backgrounds. Use one style across the whole app, and turn it off with `motion-reduce:` either way.

### Why does my page jump when the skeleton is replaced?

The skeleton and the real content are different sizes. Copy the real component's grid, gaps and heights into the skeleton, and keep text placeholders the height of the text line. A spinner always causes a jump, because it has none of the page's shape.

### Do skeleton loaders need to be client components?

No. A skeleton with only classes and markup runs as a Server Component, which is what `loading.tsx` and Suspense fallbacks expect. You only need `"use client"` in the component that fetches data in the browser, like the `ClientEvents` example above.
