UI Patterns

Loaders

Loading indicator patterns — top-of-page progress bars, skeleton screens, spinners, and when to use which.

When to use what

Not every wait needs a loader. The time thresholds below come from Miller (1968), Card, Moran & Newell (1983), and Nielsen (1993) — the indicator recommendations are common UX practice informed by those limits:

Wait timeRecommended indicator
< 1 secondNothing — instant feel
1–3 secondsSpinner or subtle indicator
2–10 secondsSkeleton screen or progress bar
> 10 secondsDeterminate progress bar with time estimate

Top-of-page progress bar

A thin bar fixed to the top of the viewport that animates during navigation. YouTube (red), GitHub (blue), and many SPAs use this pattern. The bar gives a feeling of progress even though the percentage is fabricated — it trickles forward with easing and snaps to 100% when the page actually loads.

This pattern is specifically for page transitions — it should start when a route change begins and finish when the new page renders. It is not a good fit for general-purpose "something is loading" indicators. For background data fetching (e.g. TanStack Query's background fetching indicators), a subtle inline spinner near the stale content is a better choice — it tells the user what is refreshing, not just that something is happening.

Simulated viewport
Idle

Click "Start loading" to begin the bar, then "Finish" to snap it to 100%. The trickle uses non-linear easing — fast start, slow crawl, instant completion. All animation is CSS-only.

The animation has three phases:

  1. Fast start — jumps to ~30% quickly (ease-out), giving immediate feedback.
  2. Slow crawl — creeps from 30% to 90% over several seconds (ease-in), buying time for the actual load.
  3. Instant finish — snaps to 100% and fades out when the page is ready.

All three phases are CSS-only. JavaScript only toggles a data-state attribute between idle, loading, and done.

The non-linear easing is the key UX trick — a detailed analysis from Logto explains why GitHub's bar feels fast: the rapid initial movement creates a sense of momentum, while the slow middle avoids the jarring stall at 99% that linear progress bars produce.

Keyframes
@keyframes progress-trickle {
  0% {
    width: 0%;
    /* Fast ease-out for the initial burst */
    animation-timing-function: cubic-bezier(0.1, 0.8, 0.3, 1);
  }
  8% {
    width: 30%;
    /* Smooth deceleration into the crawl phase */
    animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
  }
  50% {
    width: 60%;
    animation-timing-function: cubic-bezier(0.5, 0, 0.3, 1);
  }
  100% {
    width: 90%;
  }
}

@keyframes progress-fade-out {
  0% {
    width: 100%;
    opacity: 1;
  }
  40% {
    width: 100%;
    opacity: 1;
  }
  100% {
    width: 100%;
    opacity: 0;
  }
}

Usage — toggle a data-state attribute on the bar element:

<!-- Idle: hidden -->
<div data-state="idle" class="progress-bar" />

<!-- Loading: trickle animation starts -->
<div data-state="loading" class="progress-bar animate-progress-trickle" />

<!-- Done: snap to 100% and fade out -->
<div data-state="done" class="progress-bar animate-progress-complete" />

The per-keyframe animation-timing-function is what creates the non-linear feel. Each segment of the animation uses a different cubic-bezier curve, so the bar accelerates and decelerates within a single @keyframes rule — no JavaScript timers needed.

Libraries

@bprogress/next (~700 stars, active) — a TypeScript progress bar with first-class Next.js support (App Router and Pages Router). Successor to the deprecated next-nprogress-bar.

nextjs-toploader (~1.2k stars, active) — another drop-in component for Next.js.

@tanem/react-nprogress (~465 stars, active) — headless. Provides hooks and render props without any markup — you control the DOM entirely.

Pace.js (~15.6k stars) — framework- agnostic, automatically monitors Ajax requests, event loop lag, and document ready state. No manual start/done calls needed. Ships with 14+ themes. Less common in React projects because the automatic interception doesn't align well with React's data-fetching model.

Skeleton screens

Placeholder shapes that mirror the layout of the incoming content. They prevent layout shift and give the user a preview of what's coming.

Libraries

react-content-loader (~14k stars, ~672k weekly downloads) — SVG-based. You compose <rect> and <circle> elements inside a <ContentLoader> wrapper. An animated gradient provides the shimmer. Less than 2 kB, zero deps. Has a visual editor at skeletonreact.com where you can draw skeletons and export code:

import ContentLoader from "react-content-loader";

function CardSkeleton() {
  return (
    <ContentLoader viewBox="0 0 400 160">
      <rect x="0" y="0" rx="4" ry="4" width="400" height="100" />
      <rect x="0" y="116" rx="3" ry="3" width="250" height="16" />
      <rect x="0" y="140" rx="3" ry="3" width="180" height="12" />
    </ContentLoader>
  );
}

react-loading-skeleton (~4.2k stars, ~930k weekly downloads) — DOM-based, simpler API:

import Skeleton from "react-loading-skeleton";
import "react-loading-skeleton/dist/skeleton.css";

function ProfileSkeleton() {
  return (
    <div>
      <Skeleton circle width={48} height={48} />
      <Skeleton count={3} />
    </div>
  );
}

shadcn/ui Skeleton — a Tailwind animate-pulse div. No library needed, just copy the component:

function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
  return (
    <div
      className={cn("animate-pulse rounded-md bg-muted", className)}
      {...props}
    />
  );
}

CSS-only shimmer

The core technique without any library:

.skeleton {
  background: linear-gradient(90deg, #e0e0e0 25%, #f0f0f0 50%, #e0e0e0 75%);
  background-size: 200% 100%;
  animation: shimmer 1.5s infinite linear;
  border-radius: 4px;
}

@keyframes shimmer {
  0% {
    background-position: 200% 0;
  }
  100% {
    background-position: -200% 0;
  }
}

Add background-attachment: fixed to synchronize the shimmer wave across all skeleton elements on the page.

Best practices

  • Match the layout — skeleton shapes should mirror actual content to prevent CLS (Cumulative Layout Shift).
  • Skip short waits — a flash of skeleton is worse than no indicator. Show it only after ~200ms with a CSS animation-delay or a timeout.
  • Keep it subtle — pulse or shimmer, not bouncing or spinning.
  • Accessibility — add aria-busy="true" on the container and aria-label describing what's loading.

Spinners

Best for small, localized loading — button actions, inline fetches, component-level states.

A CSS-only spinner in one element:

.spinner {
  width: 24px;
  height: 24px;
  border: 3px solid #e0e0e0;
  border-top-color: #3b82f6;
  border-radius: 50%;
  animation: spin 0.6s linear infinite;
}

@keyframes spin {
  to {
    transform: rotate(360deg);
  }
}

CSS spinner collections:

React patterns

useTransition + isPending

Wrapping a state update in startTransition lets the current UI stay interactive while React renders the new state in the background:

const [isPending, startTransition] = useTransition();

function handleFilter(value: string) {
  startTransition(() => {
    setFilter(value);
  });
}

return (
  <button disabled={isPending}>{isPending ? "Filtering…" : "Apply"}</button>
);

Suspense boundaries

Wrap async components in <Suspense> with a fallback. Use multiple granular boundaries so each section streams in independently:

<Suspense fallback={<HeaderSkeleton />}>
  <Header />
</Suspense>
<Suspense fallback={<FeedSkeleton />}>
  <Feed />
</Suspense>
<Suspense fallback={<SidebarSkeleton />}>
  <Sidebar />
</Suspense>

Next.js loading.tsx

Place a loading.tsx alongside your page.tsx. Next.js automatically wraps the page in a <Suspense> boundary with your loading component as the fallback:

app/
  dashboard/
    page.tsx       ← async page
    loading.tsx    ← shown instantly while page resolves
// app/dashboard/loading.tsx
export default function Loading() {
  return <DashboardSkeleton />;
}

The loading UI is prefetched on link hover, so navigations feel instant.

Optimistic UI

Instead of showing a loader, update the UI immediately and assume the server will succeed. React 19 provides useOptimistic for this:

const [optimisticMessages, addOptimistic] = useOptimistic(
  messages,
  (state, newMsg: string) => [...state, { text: newMsg, pending: true }],
);

Best for idempotent, high-success-rate operations — likes, toggles, adding items to a list. Not appropriate for payments or destructive actions.

Further reading

Academic research

Guides and docs

On this page