Skeleton to Content
Breathing placeholders hand off to the real content in one cross-fade and a small lift.
The animated component in this preview is rendered from the canonical file shown here. The surrounding demo shell only provides context and is not part of the copied code.
import { useEffect, useState } from "react";
import type { CSSProperties, ReactNode } from "react";
import { motion, useReducedMotion } from "motion/react";
/**
* Vibary · Skeleton to Content
*
* Placeholder blocks breathe while data is in flight, then the real
* content cross-fades over them and lifts into place.
*
* Self-contained: depends only on `motion` (react ships with your app).
* Works with zero props; pass `loading` to drive it from your own
* request state and `children` to render your own content.
* Requires the automatic JSX runtime (default since React 17).
*/
export type SkeletonToContentProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Drive this from your request state. Left undefined, the component
* reveals itself after `revealAfterMs` so the file runs as-is. */
loading?: boolean;
/** Only consulted while `loading` is undefined. */
revealAfterMs?: number;
/** Your content. Falls back to embedded sample content. */
children?: ReactNode;
/** Block width — px number or any CSS length. */
width?: number | string;
/** Placeholder fill. A translucent neutral, so it reads on light and dark. */
placeholderColor?: string;
/** Fires once the content has finished arriving. */
onRevealed?: () => void;
};
type VariantConfig = {
breathFrom: number;
breathTo: number;
breathSeconds: number;
breathStagger: number;
lift: number;
fadeSeconds: number;
handoff: number;
spring: { type: "spring"; stiffness: number; damping: number };
};
// Quality rule: the lift springs at or near critical damping, so content
// arrives with at most one soft settle and text never bounces. Variants
// differ in breath depth, travel and speed — not in wobble.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// Shallow breath, 4px of travel, overdamped. For dense dashboards
// where several of these load at once.
subtle: {
breathFrom: 0.68,
breathTo: 0.95,
breathSeconds: 2.0,
breathStagger: 0.05,
lift: 4,
fadeSeconds: 0.3,
handoff: 0.06,
spring: { type: "spring", stiffness: 420, damping: 42 },
},
// The all-purpose setting: a readable breath and a lift you notice
// without watching for it.
default: {
breathFrom: 0.55,
breathTo: 1,
breathSeconds: 1.6,
breathStagger: 0.07,
lift: 8,
fadeSeconds: 0.34,
handoff: 0.08,
spring: { type: "spring", stiffness: 360, damping: 34 },
},
// Deeper breath, longer travel — for a single hero panel that owns
// the screen while it loads.
playful: {
breathFrom: 0.45,
breathTo: 1,
breathSeconds: 1.3,
breathStagger: 0.09,
lift: 12,
fadeSeconds: 0.38,
handoff: 0.1,
spring: { type: "spring", stiffness: 320, damping: 29 },
},
};
const PLACEHOLDER_COLOR = "rgba(127, 127, 140, 0.18)";
export default function SkeletonToContent({
variant = "default",
loading,
revealAfterMs = 1400,
children,
width = 288,
placeholderColor = PLACEHOLDER_COLOR,
onRevealed,
}: SkeletonToContentProps) {
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
const [selfRevealed, setSelfRevealed] = useState(false);
// Uncontrolled by default so the file runs on its own; the moment a
// caller passes `loading`, this timer stays out of the way.
useEffect(() => {
if (loading !== undefined) return;
const timer = setTimeout(() => setSelfRevealed(true), revealAfterMs);
return () => clearTimeout(timer);
}, [loading, revealAfterMs]);
const isLoading = loading ?? !selfRevealed;
const breathing = isLoading && !reduceMotion;
const lift = reduceMotion ? 0 : cfg.lift;
// Reduced motion keeps the whole information sequence — placeholders
// first, content second — and drops only the travel and the breathing.
const contentTransition = reduceMotion
? { duration: 0.2, ease: "easeOut" as const }
: {
// Opacity runs on its own quick curve; springing a fade looks muddy.
opacity: {
duration: cfg.fadeSeconds,
ease: "easeOut" as const,
delay: isLoading ? 0 : cfg.handoff,
},
y: { ...cfg.spring, delay: isLoading ? 0 : cfg.handoff },
};
return (
<div
aria-busy={isLoading}
style={{ display: "grid", alignItems: "start", width }}
>
{/* Skeleton and content share one grid cell. The container is
therefore sized by the taller of the two from the first frame,
so the swap can't shove the rest of the page around — a jump
would undo everything the cross-fade is doing. */}
<motion.div
aria-hidden
animate={{ opacity: isLoading ? 1 : 0 }}
transition={{ duration: cfg.fadeSeconds, ease: "easeOut" }}
style={{
gridArea: "1 / 1",
display: "flex",
flexDirection: "column",
gap: 14,
pointerEvents: "none",
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
<Placeholder
cfg={cfg}
color={placeholderColor}
breathing={breathing}
delayIndex={0}
style={{ width: 44, height: 44, borderRadius: 12 }}
/>
<div
style={{
flex: 1,
display: "flex",
flexDirection: "column",
gap: 8,
}}
>
<Placeholder
cfg={cfg}
color={placeholderColor}
breathing={breathing}
delayIndex={1}
style={{ width: "62%", height: 11 }}
/>
<Placeholder
cfg={cfg}
color={placeholderColor}
breathing={breathing}
delayIndex={2}
style={{ width: "38%", height: 9 }}
/>
</div>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
{["100%", "96%", "64%"].map((barWidth, index) => (
<Placeholder
key={barWidth}
cfg={cfg}
color={placeholderColor}
breathing={breathing}
delayIndex={3 + index}
style={{ width: barWidth, height: 10 }}
/>
))}
</div>
</motion.div>
{/* Present in the DOM from the start so the grid can measure it,
but hidden from assistive tech and from the pointer until the
data it represents is actually there. */}
<motion.div
aria-hidden={isLoading}
initial={{ opacity: 0, y: lift }}
animate={{ opacity: isLoading ? 0 : 1, y: isLoading ? lift : 0 }}
transition={contentTransition}
onAnimationComplete={() => {
if (!isLoading) onRevealed?.();
}}
style={{
gridArea: "1 / 1",
pointerEvents: isLoading ? "none" : "auto",
}}
>
{children ?? <SampleContent />}
</motion.div>
</div>
);
}
/** One placeholder block. Local component so the breath timing — the
* part worth tuning — lives in exactly one place. */
function Placeholder({
cfg,
color,
breathing,
delayIndex,
style,
}: {
cfg: VariantConfig;
color: string;
breathing: boolean;
delayIndex: number;
style: CSSProperties;
}) {
return (
<motion.div
animate={
breathing
? { opacity: [cfg.breathFrom, cfg.breathTo, cfg.breathFrom] }
: { opacity: cfg.breathTo }
}
transition={
breathing
? {
duration: cfg.breathSeconds,
repeat: Infinity,
ease: "easeInOut",
// A small offset per block keeps the panel from pulsing as
// one slab — it reads as a surface, not a blinking light.
delay: delayIndex * cfg.breathStagger,
}
: { duration: 0.2, ease: "easeOut" }
}
style={{ background: color, borderRadius: 6, ...style }}
/>
);
}
/** Embedded sample so the component renders something real with zero
* props. Replace it by passing `children`. */
function SampleContent() {
return (
<div style={{ display: "flex", flexDirection: "column", gap: 14 }}>
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
<div
style={{
width: 44,
height: 44,
borderRadius: 12,
background: "linear-gradient(135deg, #7C7CF0 0%, #4B4BB8 100%)",
flexShrink: 0,
}}
/>
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 15, fontWeight: 600, lineHeight: 1.3 }}>
Q3 Revenue Overview
</div>
<div style={{ fontSize: 12.5, opacity: 0.55, marginTop: 3 }}>
Updated 2 minutes ago
</div>
</div>
</div>
<p style={{ margin: 0, fontSize: 13, lineHeight: 1.55, opacity: 0.72 }}>
Subscription revenue grew 12% against last quarter, driven by seat
expansion in existing accounts. Churn held flat at 1.4%.
</p>
</div>
);
}About this pattern
The default loading state for anything that fetches: feeds, dashboards, profile panels. Placeholder blocks breathe on a slow cycle so the surface reads as alive rather than frozen, then the real content cross-fades over them and lifts a few pixels into place. Placeholder and content share a single grid cell, so the panel is already the right size before the data lands — the hand-off never shoves the rest of the page around, which is what separates this from simply swapping one block for another.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Photo gallery
Grey placeholders on the home grid resolve into thumbnails and titles.
Related patterns
- Table Rows PopulatePlaceholder cells resolve one column at a time, so the eye follows the fill instead of hunting for it.
- Search Results SwapStale answers dim and stay put while the fresh set cross-fades over them.
- Content Placeholder PulsePlaceholder blocks rise and fall together on one slow cadence, so the region reads as dormant rather than busy.
