Skeleton Loader With Shimmer in Next.js and Tailwind

6 min read

TL;DR 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.

On this page
  1. Pulse or shimmer?
  2. The shimmer keyframes in Tailwind v4
  3. A Skeleton component
  4. Make the skeleton match the real layout
  5. Route level: loading.tsx
  6. Panel level: one Suspense per panel
  7. Data fetched in the browser
  8. Screen readers and reduced motion
  9. Five bugs in AI-generated skeletons
  10. Trying it on a real dashboard
  11. FAQ
  12. How do I add a skeleton loader in Next.js App Router?
  13. Should I use animate-pulse or a shimmer?
  14. Why does my page jump when the skeleton is replaced?
  15. Do skeleton loaders need to be client components?

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

Day / Night Toggle running live. A theme switch with a sun that sets into a starry night.

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.

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 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, 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 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 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. Tailwind's motion-reduce: variant does it in a class. We cover the wider topic in prefers-reduced-motion in 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 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, 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.

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.