Skeleton to content with a 4px settle and clearing blur, never a flash.
Loading & progressmotion
01Preview
The first customer takes 1.2 s to load.
02Install
Copy the source into your project. It becomes yours: no package to update, no wrapper between you and the markup. It needs:
npm install motion03Usage
import { SkeletonSwap } from "@/components/ui/skeleton-swap";
import { SkeletonRow } from "@/components/ui/skeleton";
const { data, isLoading } = useQuery(["members"], fetchMembers);
<SkeletonSwap loading={isLoading} skeleton={<SkeletonRow count={3} />}>
<MemberList members={data} />
</SkeletonSwap>04Source
"use client";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useEffect, useRef, useState } from "react";
import { cn } from "@/lib/cn";
import { ease } from "@/lib/motion";
export type SwapPhase = "waiting" | "skeleton" | "holding" | "content";
type Options = {
/** Milliseconds before the skeleton appears. Data that lands sooner never shows one. */
delay?: number;
/** Once the skeleton is visible, keep it at least this long so it never flickers. */
minDuration?: number;
/** When loading starts again after content was shown: keep it (dimmed) or go back to the skeleton. */
refetch?: "keep" | "skeleton";
};
/**
* The timing on its own. waiting (skeleton reserved but invisible) → skeleton →
* holding (data is in, the minimum hasn't passed) → content. Loading again with
* refetch="keep" stays on content and reports refreshing instead.
*/
export function useSkeletonSwap(loading: boolean, { delay = 150, minDuration = 400, refetch = "keep" }: Options = {}) {
const [phase, setPhase] = useState<SwapPhase>(loading ? "waiting" : "content");
const [seen, setSeen] = useState(!loading);
const [prev, setPrev] = useState(loading);
const [swapping, setSwapping] = useState(false);
const shownAt = useRef(0);
// React to the loading prop on the same render it changes, so a fast answer
// goes straight to content without an intermediate frame.
if (loading !== prev) {
setPrev(loading);
if (loading) {
if (phase === "holding") setPhase("skeleton");
else if (!(seen && refetch === "keep")) setPhase("waiting");
} else if (phase === "waiting") {
setPhase("content");
setSeen(true);
} else if (phase === "skeleton") {
setPhase("holding");
}
}
useEffect(() => {
if (phase === "waiting") {
const id = window.setTimeout(() => {
shownAt.current = performance.now();
setPhase("skeleton");
}, delay);
return () => window.clearTimeout(id);
}
if (phase === "holding") {
const left = Math.max(0, minDuration - (performance.now() - shownAt.current));
const id = window.setTimeout(() => {
setSwapping(true);
setPhase("content");
setSeen(true);
}, left);
return () => window.clearTimeout(id);
}
}, [phase, delay, minDuration]);
// Height is only animated around a swap; the rest of the time it is plain auto.
useEffect(() => {
if (!swapping) return;
const id = window.setTimeout(() => setSwapping(false), 700);
return () => window.clearTimeout(id);
}, [swapping]);
const refreshing = loading && phase === "content";
// While waiting after content was already shown, keep showing it: never blank the region.
const showContent = phase === "content" || (phase === "waiting" && seen);
return { phase, showContent, refreshing, swapping };
}
export type SkeletonSwapProps = Omit<React.ComponentProps<"div">, "children"> &
Options & {
loading: boolean;
/** What to show while loading, shaped like the content. */
skeleton: React.ReactNode;
children: React.ReactNode;
};
export function SkeletonSwap({ loading, skeleton, delay, minDuration, refetch, className, children, ...rest }: SkeletonSwapProps) {
const { phase, showContent, refreshing, swapping } = useSkeletonSwap(loading, { delay, minDuration, refetch });
const reduce = useReducedMotion();
const inner = useRef<HTMLDivElement>(null);
const [height, setHeight] = useState<number | null>(null);
useEffect(() => {
const el = inner.current;
if (!el) return;
const ro = new ResizeObserver(([entry]) => setHeight(entry.borderBoxSize?.[0]?.blockSize ?? el.offsetHeight));
ro.observe(el);
return () => ro.disconnect();
}, []);
const animateHeight = swapping && !reduce && height !== null;
return (
<motion.div
aria-busy={loading || undefined}
data-phase={phase}
data-refreshing={refreshing || undefined}
className={cn("relative", animateHeight && "overflow-hidden", className)}
initial={false}
animate={{ height: animateHeight ? height : "auto" }}
transition={{ duration: 0.28, ease: ease.inOut }}
{...(rest as React.ComponentProps<typeof motion.div>)}
>
{/* Both layers share one grid cell, so during the crossfade the box is as tall as the taller one. */}
<div ref={inner} className="grid">
<AnimatePresence initial={false}>
{showContent ? (
<motion.div
key="content"
className="col-start-1 row-start-1 min-w-0"
initial={reduce ? { opacity: 0 } : { opacity: 0, y: 4, filter: "blur(4px)" }}
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
exit={{ opacity: 0, transition: { duration: 0.15, ease: ease.in } }}
transition={{ duration: reduce ? 0.15 : 0.32, ease: ease.out }}
>
{/* A refetch slower than 150ms dims the old data; a quick one changes nothing. */}
<div className={cn("transition-opacity duration-200", refreshing && "opacity-60 delay-150")}>{children}</div>
</motion.div>
) : (
<motion.div
key="skeleton"
aria-hidden={phase === "waiting" || undefined}
className="col-start-1 row-start-1 min-w-0"
initial={{ opacity: 0 }}
// Reserved but invisible while waiting: the space is held, nothing flashes.
animate={{ opacity: phase === "waiting" ? 0 : 1 }}
exit={{ opacity: 0, transition: { duration: reduce ? 0.12 : 0.18, ease: ease.in } }}
transition={{ duration: 0.2, ease: ease.out }}
>
{skeleton}
</motion.div>
)}
</AnimatePresence>
</div>
</motion.div>
);
}05Props
SkeletonSwap
| Prop | Type | Default | Description |
|---|---|---|---|
| loading* | boolean | — | True while the data is on its way. |
| skeleton* | ReactNode | — | What to show while loading, shaped like the content. |
| children* | ReactNode | — | The content, rendered once loading is false. |
| delay | number | 150 | Milliseconds before the skeleton fades in. Data that lands sooner goes straight to content. |
| minDuration | number | 400 | Once visible, the skeleton stays at least this long so it never flickers. |
| refetch | "keep" | "skeleton" | "keep" | Loading again after content was shown: keep the data (dimmed after 150ms), or return to the skeleton. |
useSkeletonSwap
| Prop | Type | Default | Description |
|---|---|---|---|
| loading* | boolean | — | First argument. Returns { phase, showContent, refreshing, swapping } for building your own swap. |
| options | { delay?, minDuration?, refetch? } | — | Second argument, the same timing options as the component. |
06Notes
Behavior
- During the first 150ms the skeleton is laid out but invisible, so the space is held and a fast answer swaps straight to content with no skeleton flash.
- Once shown, the skeleton holds for at least 400ms; data that arrives early waits out the rest instead of flickering.
- Skeleton and content share one grid cell during the crossfade, so the box is as tall as the taller one; if they differ, the height eases to the content's over 280ms, then returns to auto so later changes inside aren't delayed.
- A refetch keeps the old data on screen and only dims it if it takes longer than 150ms. With refetch="skeleton" the old content stays until the skeleton is due, never a blank frame.
Motion
- Content arrives from 4px below with a 4px blur clearing, opacity 0 → 1, 320ms on the expo ease-out. It settles into place rather than popping.
- The skeleton fades in over 200ms after the delay and out over 180ms on the ease-in, overlapping the content's entrance so there is no gap.
- Height only animates around a swap: 280ms ease-in-out, with overflow clipped for that moment.
- Reduced motion keeps a 150ms opacity crossfade and drops the travel, the blur and the height animation.
Accessibility
- aria-busy is set on the container for as long as loading is true, including during a refetch.
- The invisible skeleton of the first 150ms is aria-hidden; pass a Skeleton with a label to name what is loading once it shows.