Async·03.1
Skeleton Swap
Skeleton to content with zero layout shift.
Install
One dependency. The component is copied into your project, so the file is yours after that.
bun add motionUsage
import { SkeletonSwap } from "@/components/interior/skeleton-swap";
export function ProfileBio({ userId }: { userId: string }) {
const [bio, setBio] = useState<string | null>(null);
useEffect(() => {
let alive = true;
fetch(`/api/users/${userId}`)
.then((r) => r.json())
.then((u) => alive && setBio(u.bio));
return () => {
alive = false;
};
}, [userId]);
return (
<article className="rounded-[14px] border border-stone-200 p-4 dark:border-white/[0.16]">
<h3 className="text-[13px] font-medium">Priya Raman</h3>
<SkeletonSwap
ready={bio !== null}
lines={3}
lineHeight={21}
label="Profile"
className="mt-2"
>
{bio ? (
<p className="text-[13.5px] leading-[21px] text-stone-500 dark:text-stone-400">
{bio}
</p>
) : null}
</SkeletonSwap>
<footer className="mt-3 border-t border-stone-200 pt-3 dark:border-white/[0.16]">
Member since 2019
</footer>
</article>
);
}Source
"use client";
import { useEffect, useRef, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
const CROSSFADE = {
type: "spring",
stiffness: 260,
damping: 34,
mass: 0.8,
} as const;
const WIDTHS = [100, 93, 97, 88, 95, 91] as const;
function widthFor(index: number, total: number) {
if (total > 1 && index === total - 1) return 62;
return WIDTHS[(index * 7 + 3) % WIDTHS.length];
}
export type UseSkeletonSwapOptions = {
ready: boolean;
delay?: number;
minVisible?: number;
};
export function useSkeletonSwap({
ready,
delay = 120,
minVisible = 380,
}: UseSkeletonSwapOptions) {
const [visible, setVisible] = useState(false);
const shownAt = useRef(0);
useEffect(() => {
if (!ready) {
if (visible) return;
const t = setTimeout(() => {
shownAt.current = performance.now();
setVisible(true);
}, delay);
return () => clearTimeout(t);
}
if (!visible) return;
const rest = Math.max(0, minVisible - (performance.now() - shownAt.current));
const t = setTimeout(() => setVisible(false), rest);
return () => clearTimeout(t);
}, [ready, visible, delay, minVisible]);
return { showSkeleton: visible, busy: !ready };
}
export type SkeletonSwapProps = {
ready: boolean;
children: React.ReactNode;
lines?: number;
lineHeight?: number;
barHeight?: number;
reserve?: number;
delay?: number;
minVisible?: number;
label?: string;
skeleton?: React.ReactNode;
className?: string;
};
export function SkeletonSwap({
ready,
children,
lines = 3,
lineHeight = 21,
barHeight = 9,
reserve,
delay = 120,
minVisible = 380,
label,
skeleton,
className = "",
}: SkeletonSwapProps) {
const { showSkeleton } = useSkeletonSwap({ ready, delay, minVisible });
const reduced = useReducedMotion();
const shell = useRef<HTMLDivElement>(null);
const body = useRef<HTMLDivElement>(null);
const [scrollable, setScrollable] = useState(false);
const box = reserve ?? lines * lineHeight;
useEffect(() => {
const el = shell.current;
const inner = body.current;
if (!el || typeof ResizeObserver === "undefined") return;
const check = () => setScrollable(el.scrollHeight - el.clientHeight > 1);
check();
const ro = new ResizeObserver(check);
ro.observe(el);
if (inner) ro.observe(inner);
return () => ro.disconnect();
}, []);
return (
<div
ref={shell}
aria-busy={!ready}
aria-label={label}
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
tabIndex={scrollable ? 0 : undefined}
style={{ height: box }}
className={`relative grid overflow-y-auto overscroll-contain text-stone-700 dark:text-stone-200 ${className}`}
>
<motion.div
ref={body}
className="col-start-1 row-start-1 min-w-0"
initial={false}
animate={
reduced
? { opacity: showSkeleton ? 0 : 1 }
: {
opacity: showSkeleton ? 0 : 1,
scale: showSkeleton ? 0.99 : 1,
filter: showSkeleton ? "blur(4px)" : "blur(0px)",
}
}
transition={reduced ? { duration: 0 } : CROSSFADE}
style={{
transformOrigin: "top left",
pointerEvents: showSkeleton ? "none" : undefined,
}}
>
{children}
</motion.div>
<AnimatePresence initial={false}>
{showSkeleton ? (
<motion.div
key="skeleton"
aria-hidden
className="pointer-events-none col-start-1 row-start-1 w-full self-start"
initial={reduced ? { opacity: 1 } : { opacity: 0 }}
animate={{ opacity: 1 }}
exit={reduced ? { opacity: 0 } : { opacity: 0, filter: "blur(3px)" }}
transition={reduced ? { duration: 0 } : CROSSFADE}
>
{skeleton ?? (
<div className="w-full">
{Array.from({ length: lines }, (_, i) => (
<div
key={i}
className="flex items-center"
style={{ height: lineHeight }}
>
<div
className="rounded-[5px] bg-stone-200 dark:bg-white/15"
style={{
height: barHeight,
width: `${widthFor(i, lines)}%`,
}}
/>
</div>
))}
</div>
)}
</motion.div>
) : null}
</AnimatePresence>
{label ? (
<span role="status" className="sr-only">
{ready ? `${label} loaded` : ""}
</span>
) : null}
</div>
);
}Props
readybooleanFlips to true when the real content is available. Everything else is timing around this one value.
childrenReact.ReactNodeThe real content. Mount it only once the data exists; the component holds the box either way.
lines3numberHow many text lines the skeleton draws, and how much height it reserves.
lineHeight21numberPixel line height of the content this stands in for. Match the text's own leading and the reserve is exact.
barHeight9numberHeight of the bar drawn inside each reserved line. Purely visual; it does not change the reserve.
reservelines * lineHeightnumberExplicit box height in pixels. Use it when the content is not text: media, a chart, a fixed card.
delay120numberMilliseconds of silence before the skeleton is allowed to paint. A response faster than this never shows one.
minVisible380numberOnce painted, the skeleton stays at least this long, so it cannot blink.
labelundefinedstringNames the region and announces "<label> loaded" once. Omit it and the component stays silent apart from aria-busy.
skeletonundefinedReact.ReactNodeReplaces the default bars with your own placeholder shape. Pair it with reserve so the box still matches.
className""stringAppended last, so callers win on every utility.