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 time | Recommended indicator |
|---|---|
| < 1 second | Nothing — instant feel |
| 1–3 seconds | Spinner or subtle indicator |
| 2–10 seconds | Skeleton screen or progress bar |
| > 10 seconds | Determinate 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.
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:
- Fast start — jumps to ~30% quickly (ease-out), giving immediate feedback.
- Slow crawl — creeps from 30% to 90% over several seconds (ease-in), buying time for the actual load.
- 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 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-delayor a timeout. - Keep it subtle — pulse or shimmer, not bouncing or spinning.
- Accessibility — add
aria-busy="true"on the container andaria-labeldescribing 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:
- cssloaders.github.io — large collection of single-element CSS loaders
- css-loaders.com — 600+ CSS loading animations
- SpinKit by Tobias Ahlin — classic set of CSS spinners
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
- Miller (1968) — "Response time in man-computer conversational transactions" — the original 0.1 s / 1 s / 10 s response-time limits.
- Card, Moran & Newell (1983) — The Psychology of Human-Computer Interaction — formalized the response-time thresholds into a cognitive model.
- Harrison, Yeo & Hudson (2010) — "Faster Progress Bars" — visual augmentations (ribbing, deceleration) reduce perceived duration by ~11%. CHI '10.
- Mejtoft, Langstrom & Soderstrom (2018) — "The effect of skeleton screens" — skeleton screens scored higher on perceived speed vs. spinners on average, but no statistically significant difference. ECCE '18.
- Sørum & Andersen (2020) — "Does the Use of Skeleton Screens Improve Perceived Performance?" — skeletons did not consistently outperform spinners; mixed results.
- Ziat et al. (2022) — "Malleability of time through progress bars and throbbers" — more incremental steps in progress bars shorten perceived duration. Scientific Reports.
Guides and docs
- Why GitHub's loading bar looks good — the psychology of non-linear easing in progress bars.
- Nielsen (1993) — Response Times: The 3 Important Limits — the widely cited practitioner summary of the time thresholds.
- React Suspense — official docs on streaming and fallbacks.
- Next.js Loading UI
— the
loading.tsxconvention.