Content Placeholder Pulse
Placeholder blocks rise and fall together on one slow cadence, so the region reads as dormant rather than busy.
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 type { CSSProperties } from "react";
import { motion, useReducedMotion } from "motion/react";
/**
* Vibary · Content Placeholder Pulse
*
* Placeholder blocks that breathe in unison on one slow cadence. The
* whole group is driven by a single opacity animation on the wrapper
* rather than one per block, which is the entire design: staggered
* placeholders read as a sweep and drifting ones read as a fault,
* whereas a group that rises and falls together reads as one surface
* waiting. It loops for as long as the request takes, so the cadence
* sits deliberately below the rate at which motion starts pulling the
* eye back.
*
* Self-contained: depends only on `motion` (react ships with your app).
* Surfaces are mixed from the inherited text color, so the panel reads
* correctly on a light page and on a dark one.
* Works with zero props.
* Requires the automatic JSX runtime (default since React 17).
*/
export type ContentPlaceholderPulseProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Whether the request is still in flight. */
active?: boolean;
/** Number of body lines in the sample block. */
lines?: number;
/** Placeholder fill. A translucent neutral, so it reads in either theme. */
placeholderColor?: string;
/** Announced to assistive technology while the pulse runs. */
loadingLabel?: string;
/** Panel width — px number or any CSS length. */
width?: number | string;
};
type VariantConfig = {
/** Opacity at the bottom of the breath. */
from: number;
/** Opacity at the top of the breath. */
to: number;
/** Seconds for a full down-and-up cycle. */
seconds: number;
};
// Quality rule: opacity only, and one cycle slow enough to be ignorable.
// Nothing here scales — a placeholder that grows and shrinks suggests
// the content will do the same when it lands, and it will not. Variants
// change the depth and pace of the breath, nothing else.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// Barely perceptible. For a page with several waiting regions, where
// a deeper breath would turn the whole layout into a heartbeat.
subtle: {
from: 0.72,
to: 1,
seconds: 2.6,
},
// The all-purpose setting: clearly alive, still ignorable.
default: {
from: 0.56,
to: 1,
seconds: 2,
},
// A deeper, slightly quicker breath for a single panel that is the
// only thing on screen.
playful: {
from: 0.42,
to: 1,
seconds: 1.6,
},
};
const PLACEHOLDER_COLOR = "rgba(127, 127, 140, 0.2)";
/** Theme-adaptive neutral: mixing the inherited text color with
* transparent yields a surface and border that are correctly toned in
* either theme. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
const LINE_WIDTHS = ["100%", "94%", "88%", "66%", "78%", "52%"];
export default function ContentPlaceholderPulse({
variant = "default",
active = true,
lines = 4,
placeholderColor = PLACEHOLDER_COLOR,
loadingLabel = "Loading content",
width = 320,
}: ContentPlaceholderPulseProps) {
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
const breathing = active && !reduceMotion;
// Reduced motion keeps the placeholders and their meaning — this
// region is waiting — and drops the breath entirely, resting at the
// midpoint rather than freezing at either extreme.
const restOpacity = reduceMotion ? (cfg.from + cfg.to) / 2 : cfg.to;
const block = (style: CSSProperties) => ({
...style,
background: placeholderColor,
borderRadius: 6,
});
return (
<div
role="status"
aria-busy={active}
style={{
width,
padding: 18,
borderRadius: 16,
border: `1px solid ${tone(12)}`,
background: tone(4),
position: "relative",
}}
>
{/* Screen readers get the state in words; the pulse is decoration
and is hidden from them entirely. */}
<span
style={{
position: "absolute",
width: 1,
height: 1,
overflow: "hidden",
clipPath: "inset(50%)",
whiteSpace: "nowrap",
}}
>
{active ? loadingLabel : ""}
</span>
{/* One animation for the entire group. Per-block timelines drift
apart over a long wait — a few hundred milliseconds of skew and
the panel stops looking like it is breathing and starts looking
like it is glitching. */}
<motion.div
aria-hidden
animate={
breathing ? { opacity: [cfg.to, cfg.from, cfg.to] } : { opacity: restOpacity }
}
transition={
breathing
? { duration: cfg.seconds, repeat: Infinity, ease: "easeInOut" }
: { duration: 0.3, ease: "easeOut" }
}
style={{ display: "flex", flexDirection: "column", gap: 16 }}
>
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
<div style={block({ width: 40, height: 40, borderRadius: 12 })} />
<div
style={{
flex: 1,
display: "flex",
flexDirection: "column",
gap: 8,
}}
>
<div style={block({ width: "58%", height: 11 })} />
<div style={block({ width: "34%", height: 9 })} />
</div>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 11 }}>
{Array.from({ length: Math.max(1, lines) }, (_, index) => (
<div
key={index}
style={block({
width: LINE_WIDTHS[index % LINE_WIDTHS.length],
height: 10,
})}
/>
))}
</div>
<div style={{ display: "flex", gap: 8 }}>
<div style={block({ width: 72, height: 24, borderRadius: 12 })} />
<div style={block({ width: 54, height: 24, borderRadius: 12 })} />
</div>
</motion.div>
</div>
);
}About this pattern
The quietest waiting state in the library, and the one to reach for when the wait might be long. Every block is driven by a single opacity cycle on their shared wrapper, not one animation each: offset blocks read as a sweep and blocks that drift out of phase read as a fault, whereas a group that rises and falls as one reads as a single surface holding its place. The cadence is deliberately slow — around two seconds for a full breath — because this loops for the entire request and anything brisker starts pulling the eye back every time it comes around. Opacity is the only property in play; a placeholder that grows and shrinks promises the real content will do the same, and it will not.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Chat thread
Channel and profile panes hold softly fading blocks while their data is fetched.
Related patterns
- Shimmer SweepA soft highlight travels across placeholder blocks, each one a beat behind the last.
- Skeleton to ContentBreathing placeholders hand off to the real content in one cross-fade and a small lift.
- Background Refresh HintA two-pixel tinted band travels the panel's top edge while data refetches, without interrupting reading.