Page Load Animation in React: First Paint, Stagger, Skeleton to Content
How to handle page load animations in React — from first paint flicker to skeleton-to-content transitions and stagger sequences that don't tank performance.
Why Page Load Animation Is Harder Than It Looks
Most tutorials show you how to animate a single component. Page load animation is a different problem entirely — you're coordinating async data, React's render cycle, browser paint timing, and user perception all at once. Get any one of those wrong and your app either flickers white for 200ms or shows a janky half-loaded state that looks like a bug.
In practice, the hard part isn't the CSS. It's deciding *when* to start the animation. React doesn't give you a single "page is ready" event. You're stitching together useEffect, Suspense boundaries, data fetching state, and sometimes requestAnimationFrame to get the timing right. Honestly, most apps just skip this coordination entirely — and it shows.
That said, getting it right pays off. Perceived performance is a real metric. A skeleton that transitions smoothly into real content at 300ms feels faster than a blank screen that loads at 150ms. Users remember the feel of a page more than the raw speed. Worth building this properly.
This guide walks through the full stack: first paint (pre-hydration), skeleton loaders, stagger sequences, and the skeleton-to-content swap. We'll use Framer Motion for most examples, but the patterns apply to GSAP, css-stagger-animation, or even raw CSS transitions.
First Paint: Handling the Pre-Hydration Flash
The first render in a React app — especially Next.js — has a brutal window where HTML is on screen but JavaScript hasn't run yet. If your page starts visible and then snaps into position once JS loads, that's a First Contentful Paint problem, not an animation problem. Fix the layout stability first.
For client-rendered apps, the classic move is rendering the shell immediately and fading it in on mount. This avoids the white flash while still getting content on screen fast. A 16px opacity fade is basically free on the GPU — it's a compositor-layer property, not a layout repaint.
import { useEffect, useState } from 'react'
export function PageShell({ children }: { children: React.ReactNode }) {
const [visible, setVisible] = useState(false)
useEffect(() => {
// Next frame after mount — avoids paint flash
const id = requestAnimationFrame(() => setVisible(true))
return () => cancelAnimationFrame(id)
}, [])
return (
<div
style={{
opacity: visible ? 1 : 0,
transition: 'opacity 200ms ease-out',
}}
>
{children}
</div>
)
}The requestAnimationFrame trick here is important. If you set visible to true directly in useEffect, React sometimes batches it with the initial render and you skip the opacity-0 frame entirely — no animation. rAF forces it to the next frame, which is exactly what you want.
Quick aside: for Next.js App Router, you can handle this at the layout level with a server-rendered fade-in using @starting-style — check out the css-starting-style article. It's cleaner for static content since there's no JS needed at all.
Building a Proper Skeleton Loader
Skeleton loaders get misused constantly. The goal is to match the *shape* of the real content closely enough that the swap feels like a reveal, not a replacement. If your skeleton is a generic gray rectangle and your content is a 3-column card grid, you've wasted the whole technique.
Here's a skeleton that actually matches a card layout, with the shimmer animation running entirely on the GPU via background-position. No JavaScript needed for the shimmer itself — keep animations on compositor-friendly properties whenever possible.
function CardSkeleton() {
return (
<div className="skeleton-card">
<div className="skeleton-img" />
<div className="skeleton-line skeleton-line--wide" />
<div className="skeleton-line skeleton-line--narrow" />
</div>
)
}
// CSS
// .skeleton-card {
// border-radius: 12px;
// overflow: hidden;
// background: #1a1a1a;
// }
// .skeleton-img {
// height: 180px;
// background: linear-gradient(90deg, #222 25%, #333 50%, #222 75%);
// background-size: 200% 100%;
// animation: shimmer 1.5s infinite;
// }
// .skeleton-line { height: 14px; margin: 12px 16px; border-radius: 4px; }
// .skeleton-line--wide { width: 75%; }
// .skeleton-line--narrow { width: 45%; }
// @keyframes shimmer { to { background-position: -200% 0; } }Worth noting: the shimmer direction goes right-to-left when you use -200% as the end background-position. That feels more natural on LTR layouts — it matches the direction eyes scan. Small thing, but it looks more polished than the leftward shimmer you see in most skeleton tutorials from 2022.
For a pre-built version, the Empire UI library ships glassmorphism-ready skeleton components if you're already using that design language. Saves you from hand-rolling the blur + border math. That said, the custom version above gives you full control over the shimmer timing and color.
The Skeleton-to-Content Swap
This is the moment most apps handle badly. Data loads, skeleton disappears, content appears — and if those two aren't coordinated, you get a jarring pop. The fix is to cross-fade: fade the skeleton *out* while simultaneously fading the content *in*, with a tiny delay so the browser doesn't try to animate both at the same frame.
Framer Motion's AnimatePresence is the cleanest way to handle this in React. You render either the skeleton or the content based on your loading state, and AnimatePresence handles the exit animation of whatever's leaving.
import { AnimatePresence, motion } from 'framer-motion'
function CardOrSkeleton({ data, isLoading }) {
return (
<AnimatePresence mode="wait">
{isLoading ? (
<motion.div
key="skeleton"
initial={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
>
<CardSkeleton />
</motion.div>
) : (
<motion.div
key="content"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.2, delay: 0.05 }}
>
<Card data={data} />
</motion.div>
)}
</AnimatePresence>
)
}The mode="wait" on AnimatePresence is critical — it waits for the skeleton's exit animation to finish before mounting the content. Without it, you get both elements on screen simultaneously, which looks terrible. The 0.05s delay on the content entrance gives the skeleton a head start leaving before content starts arriving.
Look, this pattern adds about 40 lines of code to your app. It's worth it. A smooth skeleton-to-content transition is one of those things users don't consciously notice — but they absolutely notice the absence of it when it's gone. The perceived quality jump is real.
Stagger Animations for Card Grids and Lists
Stagger is when multiple elements animate in sequence with a small offset — think a card grid where each card fades in 80ms after the previous one. Done right it feels elegant. Done wrong (too slow, too dramatic) it feels like an intro screen from a 2019 agency portfolio.
The modern approach avoids animating each card individually from JavaScript. Instead, use CSS custom properties to set a --delay on each element and let CSS handle the timing. This is much cheaper — the browser can batch GPU work for composited properties without going back to JS per frame.
function CardGrid({ cards }) {
return (
<div className="grid">
{cards.map((card, i) => (
<div
key={card.id}
className="card-animate-in"
style={{ '--delay': `${i * 60}ms` } as React.CSSProperties}
>
<Card data={card} />
</div>
))}
</div>
)
}
// CSS:
// .card-animate-in {
// opacity: 0;
// translate: 0 16px;
// animation: card-enter 350ms ease-out var(--delay, 0ms) forwards;
// }
// @keyframes card-enter {
// to { opacity: 1; translate: 0 0; }
// }A couple of things to note here. First, translate (the standalone property, not the transform: translate() shorthand) is compositor-friendly in 2024+ browsers — it doesn't trigger layout. Second, cap your stagger at around 12 items. A grid of 24 cards where the last one takes 1.44 seconds to appear is just annoying. After 8-10 items, you can either repeat the pattern or drop the stagger entirely.
If you want Framer Motion for this pattern, their staggerChildren variant is solid. You can also check out react-animation-framer-motion for a full breakdown of the variant API. Either way, keep the per-item delay under 80ms and the animation duration under 400ms — anything heavier starts feeling sluggish on mobile.
One more thing — if you're building a browse components workflow, pre-built stagger utilities in the Empire UI library already have these timing values tuned. Saves you the trial-and-error on the delay math.
Performance: What to Watch and What to Ignore
Most animation performance advice you'll read online is either wrong or outdated. Yes, stick to opacity and transform (or their modern equivalents translate, scale, rotate). No, you don't need will-change: transform on everything — that actually creates compositing layers that eat memory, and Chrome's heuristics since 2023 handle layer promotion better than you can manually.
The real performance killer for page load animations is not GPU cost — it's JavaScript cost during the animation. If you're running a useEffect that sets state every frame, or a scroll listener without throttling, you'll get jank even if your CSS is perfectly optimized. The animation thread and the JS thread fight each other.
// Bad: setState in a tight loop blocks animation
useEffect(() => {
let frame: number
const tick = () => {
setCount(c => c + 1) // This flushes React state every frame
frame = requestAnimationFrame(tick)
}
frame = requestAnimationFrame(tick)
return () => cancelAnimationFrame(frame)
}, [])
// Better: Use a ref for animation state, only call setState when needed
const countRef = useRef(0)
useEffect(() => {
let frame: number
const tick = () => {
countRef.current += 1
// Only update React state at milestones, not every frame
if (countRef.current % 60 === 0) setDisplayCount(countRef.current)
frame = requestAnimationFrame(tick)
}
frame = requestAnimationFrame(tick)
return () => cancelAnimationFrame(frame)
}, [])For measuring this stuff, open DevTools > Performance tab, record while the page loads, and look for "Long Task" markers. Anything over 50ms on the main thread during your animation is a problem. The react-performance-guide article goes deeper on profiling if you want to chase specific bottlenecks.
Honestly, for 90% of page load animations, you don't need any of this optimization. A simple CSS fade-in on the page wrapper plus a skeleton loader will cover most use cases without touching JavaScript animation threads at all. Reach for Framer Motion or GSAP when you have genuinely complex choreography — not for a basic entrance fade.
Putting It Together: A Full Page Load Sequence
Here's how all these pieces fit into a realistic component. The pattern: render the skeleton immediately, fetch data, swap to content with a cross-fade, and stagger the cards in. This covers every state the page can be in without any visual jank.
import { AnimatePresence, motion } from 'framer-motion'
import { useEffect, useState } from 'react'
type Card = { id: string; title: string; desc: string }
export function ProductGrid() {
const [cards, setCards] = useState<Card[]>([])
const [loading, setLoading] = useState(true)
const [visible, setVisible] = useState(false)
useEffect(() => {
requestAnimationFrame(() => setVisible(true))
}, [])
useEffect(() => {
fetchCards().then(data => {
setCards(data)
setLoading(false)
})
}, [])
return (
<div style={{ opacity: visible ? 1 : 0, transition: 'opacity 150ms ease-out' }}>
<AnimatePresence mode="wait">
{loading ? (
<motion.div key="skeletons" exit={{ opacity: 0 }} transition={{ duration: 0.15 }}>
<div className="grid">
{Array.from({ length: 6 }).map((_, i) => (
<CardSkeleton key={i} />
))}
</div>
</motion.div>
) : (
<motion.div key="cards" initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ duration: 0.2 }}>
<div className="grid">
{cards.map((card, i) => (
<div
key={card.id}
className="card-animate-in"
style={{ '--delay': `${i * 60}ms` } as React.CSSProperties}
>
<ProductCard data={card} />
</div>
))}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
)
}This is the full stack in about 40 lines. Page shell fades in on first paint, skeleton renders immediately while data loads, cross-fade swaps on data arrival, cards stagger in. Each piece is independent — you can swap out the stagger for a different animation without touching the skeleton logic.
Worth noting: if you're using React 18's useDeferredValue or Suspense for data fetching, the skeleton part changes. You'd put the skeleton in the Suspense fallback prop instead of managing loading state manually. Same visual result, cleaner integration with the React data model. Check react-suspense-guide for that pattern.
For more advanced transitions between full pages rather than just components, page-transitions-nextjs covers the Next.js-specific setup with App Router's loading.tsx and the view transitions API.
FAQ
Page load animation is what happens when your page first appears — skeletons, fade-ins, entrance sequences. Page transitions are between routes — sliding or fading as you navigate from one URL to another. Different problems, different solutions.
Use CSS for the shimmer effect — it's a pure compositor animation and costs nothing. Use Framer Motion for the skeleton-to-content swap if you need the exit/entrance coordination that AnimatePresence gives you. Don't use both unnecessarily.
Cap it at 8 to 12 items. After that, the last card's delay feels like a bug rather than a design choice. You can stagger the first 8 and animate the rest simultaneously at the same delay as item 8.
It can if you're animating layout-triggering properties like height or width — that pushes your CLS score. Stick to opacity and transform-based properties, which run off the main thread and don't affect layout shift scores.