remembered skeleton.
A loading skeleton that measures the real content once and redraws it exactly from then on, so the page stops shifting when data lands.
Loading component...
live component, try it
what to expect.
Press load again and watch the captions
The left caption jumps, the right one holds
The first load is a guess in both columns
how it works.
Most skeletons are a different size from the content that lands on top of them, so they trade one layout shift for two: once when the placeholder appears, and again when it turns out to be wrong. The fix is not a better guess, it is remembering. A resize observer measures the real box once and keeps it in session storage, so from the second load onward the swap costs nothing. The left column is what every skeleton does, the right one is what this one does, and the caption under each is the part to watch.
the component.
import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
export interface Part {
top: number;
height: number;
left: number;
width: number;
radius: string;
}
export interface Box {
height: number;
parts: Part[];
}
export type Shape =
| { kind: "block"; aspect: string }
| { kind: "line"; width?: string };
const EXIT_MS = 180;
const KEY = (id: string) => `skeleton:${id}`;
function isBox(value: unknown): value is Box {
if (typeof value !== "object" || value === null) return false;
const box = value as Partial<Box>;
return (
typeof box.height === "number" &&
Number.isFinite(box.height) &&
box.height > 0 &&
Array.isArray(box.parts)
);
}
function recall(id: string): Box | null {
try {
const raw = sessionStorage.getItem(KEY(id));
if (!raw) return null;
const parsed: unknown = JSON.parse(raw);
return isBox(parsed) ? parsed : null;
} catch {
return null;
}
}
function remember(id: string, box: Box): void {
try {
// Private mode and blocked storage throw here, hence the try.
sessionStorage.setItem(KEY(id), JSON.stringify(box));
} catch {
}
}
function measure(root: HTMLElement): Box | null {
const base = root.getBoundingClientRect();
if (base.width <= 0 || base.height <= 0) return null;
const parts: Part[] = [];
const push = (r: DOMRect, radius: string) => {
if (r.width <= 0 || r.height <= 0) return;
parts.push({
top: r.top - base.top,
height: r.height,
left: (r.left - base.left) / base.width,
width: r.width / base.width,
radius,
});
};
// Text is traced per line box, so the skeleton gets the short last line too.
for (const el of root.querySelectorAll<HTMLElement>("[data-skel]")) {
if (el.dataset.skel === "text") {
const range = document.createRange();
range.selectNodeContents(el);
for (const rect of range.getClientRects()) push(rect as DOMRect, "999px");
range.detach();
} else {
push(el.getBoundingClientRect(), getComputedStyle(el).borderRadius);
}
}
return parts.length > 0 ? { height: base.height, parts } : null;
}
export default function Skeleton({
id,
loading,
fallback,
remember: remembers = true,
children,
}: {
id: string;
loading: boolean;
fallback: Shape[];
remember?: boolean;
children: ReactNode;
}) {
const [box, setBox] = useState(() => (remembers ? recall(id) : null));
const ref = useRef<HTMLDivElement>(null);
const [held, setHeld] = useState(loading);
useEffect(() => {
if (loading) {
setHeld(true);
return;
}
const timer = window.setTimeout(() => setHeld(false), EXIT_MS);
return () => window.clearTimeout(timer);
}, [loading]);
useEffect(() => {
const el = ref.current;
if (!el || loading || !remembers) return;
// Measure the real content once it lands and remember it for the next load.
const ro = new ResizeObserver(() => {
const next = measure(el);
if (!next) return;
remember(id, next);
setBox(next);
});
ro.observe(el);
return () => ro.disconnect();
}, [id, loading, remembers]);
return (
<div className="skeleton-swap">
{held && (
<div
className={box ? "skeleton skeleton--traced" : "skeleton skeleton--drawn"}
aria-busy="true"
data-remembered={String(Boolean(box))}
data-leaving={String(!loading)}
style={box ? { height: box.height } : undefined}
>
{box
? box.parts.map((part, i) => (
<span
key={i}
className="skeleton-part"
aria-hidden="true"
style={
{
"--i": i,
top: part.top,
height: part.height,
left: `${(part.left * 100).toFixed(3)}%`,
width: `${(part.width * 100).toFixed(3)}%`,
borderRadius: part.radius,
} as CSSProperties
}
/>
))
: fallback.map((shape, i) => (
<span
key={i}
className="skeleton-part"
aria-hidden="true"
data-shape={shape.kind}
style={
{
"--i": i,
...(shape.kind === "block"
? { aspectRatio: shape.aspect }
: { width: shape.width ?? "100%" }),
} as unknown as CSSProperties
}
/>
))}
</div>
)}
{!loading && (
<div className="skeleton-real" ref={ref}>
{children}
</div>
)}
</div>
);
}