Sample Data Populate
Example figures sweep into an empty report so a new account can see what the product does before it has data of its own.
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 { useState } from "react";
import type { ReactNode } from "react";
import { motion, useReducedMotion } from "motion/react";
/**
* Vibary · Sample Data Populate
*
* An empty report fills with example figures so a new account can see
* what the product does before it has any data of its own. Placeholder
* bars dissolve as real values rise into their place, row by row.
*
* Self-contained: depends only on `react` and `motion`. Neutrals are
* mixed from the inherited text color, so it reads on light and dark
* pages alike. Works with zero props; tune via `variant`, `rows`,
* `accent`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type SampleRow = {
source: string;
sessions: string;
change: string;
/** Drives the color of the change cell. */
down?: boolean;
};
export type SampleDataPopulateProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
title?: string;
/** Your own example rows. The embedded sample is used when omitted. */
rows?: SampleRow[];
/** Label of the button that fills the table. */
actionLabel?: string;
/** Badge and button color. */
accent?: string;
/** Fires when the example data is put in. */
onPopulate?: () => void;
};
type VariantConfig = {
/** px each value rises into its cell. */
rise: number;
/** Seconds between one row and the next. */
rowStagger: number;
/** Seconds between the cells within a row. */
cellStagger: number;
/** Seconds a value takes to arrive. */
valueSeconds: number;
/** Seconds a placeholder bar takes to clear. */
barSeconds: number;
};
// Quality rule: values are text, so they translate and fade only — no
// scale, no rebound, nothing that would make a figure hard to read
// while it lands. Variants change pace and travel.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// Almost immediate. For a table that fills on every page view.
subtle: {
rise: 3,
rowStagger: 0.035,
cellStagger: 0.02,
valueSeconds: 0.2,
barSeconds: 0.12,
},
// A readable sweep down the table. All-purpose.
default: {
rise: 6,
rowStagger: 0.06,
cellStagger: 0.035,
valueSeconds: 0.26,
barSeconds: 0.16,
},
// A slower fill, for the first time an empty product is shown off.
playful: {
rise: 9,
rowStagger: 0.09,
cellStagger: 0.05,
valueSeconds: 0.3,
barSeconds: 0.2,
},
};
const SAMPLE_ROWS: SampleRow[] = [
{ source: "Direct", sessions: "4,120", change: "+12%" },
{ source: "Search", sessions: "3,486", change: "+6%" },
{ source: "Referral", sessions: "1,905", change: "-3%", down: true },
{ source: "Social", sessions: "862", change: "+21%" },
];
const UP = "#2FA36B";
const DOWN = "#E0564D";
/** Placeholder bar widths, so the empty table does not read as a grid
* of identical blocks. */
const BAR_WIDTHS = ["68%", "54%", "76%", "46%"];
/** Theme-adaptive neutral: mixing the text color in scope with
* `transparent` lands correctly on light and dark surfaces alike. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
const COLUMNS = "1fr 64px 52px";
export default function SampleDataPopulate({
variant = "default",
title = "Traffic by source",
rows = SAMPLE_ROWS,
actionLabel = "Use sample data",
accent = "#5B5BD6",
onPopulate,
}: SampleDataPopulateProps) {
const [filled, setFilled] = useState(false);
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
const rise = reduceMotion ? 0 : cfg.rise;
const rowStagger = reduceMotion ? cfg.rowStagger * 0.5 : cfg.rowStagger;
const populate = () => {
setFilled(true);
onPopulate?.();
};
/** Delay for the cell at (row, column) — down the table and across it,
* so the fill reads as a sweep rather than a flash. */
const delayFor = (row: number, column: number) =>
filled ? row * rowStagger + column * (reduceMotion ? 0 : cfg.cellStagger) : 0;
return (
<div
style={{
width: 320,
boxSizing: "border-box",
padding: 16,
borderRadius: 18,
border: `1px solid ${tone(12)}`,
background: tone(5),
}}
>
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
marginBottom: 12,
}}
>
<span style={{ fontSize: 13, fontWeight: 650 }}>{title}</span>
<motion.span
initial={false}
animate={{ opacity: filled ? 1 : 0, y: filled ? 0 : -rise }}
transition={{
duration: 0.24,
delay: filled ? rowStagger * rows.length + 0.1 : 0,
ease: "easeOut",
}}
style={{
padding: "2px 7px",
fontSize: 10,
fontWeight: 700,
letterSpacing: 0.3,
borderRadius: 6,
color: accent,
background: `color-mix(in srgb, ${accent} 14%, transparent)`,
}}
>
SAMPLE
</motion.span>
<button
type="button"
onClick={filled ? () => setFilled(false) : populate}
style={{
marginLeft: "auto",
padding: "6px 11px",
fontSize: 11.5,
fontWeight: 650,
fontFamily: "inherit",
color: filled ? "inherit" : "#ffffff",
background: filled ? "transparent" : accent,
border: filled ? `1px solid ${tone(16)}` : "none",
borderRadius: 9,
opacity: filled ? 0.7 : 1,
cursor: "pointer",
}}
>
{filled ? "Clear" : actionLabel}
</button>
</div>
<div
style={{
display: "grid",
gridTemplateColumns: COLUMNS,
gap: 10,
padding: "0 2px 8px",
fontSize: 10.5,
fontWeight: 700,
letterSpacing: 0.4,
opacity: 0.4,
borderBottom: `1px solid ${tone(10)}`,
}}
>
<span>SOURCE</span>
<span style={{ textAlign: "right" }}>SESSIONS</span>
<span style={{ textAlign: "right" }}>CHANGE</span>
</div>
{rows.map((row, rowIndex) => (
<div
key={row.source}
style={{
display: "grid",
gridTemplateColumns: COLUMNS,
gap: 10,
alignItems: "center",
padding: "11px 2px",
borderBottom:
rowIndex === rows.length - 1 ? "none" : `1px solid ${tone(7)}`,
}}
>
<Cell
filled={filled}
barWidth={BAR_WIDTHS[rowIndex % BAR_WIDTHS.length]}
delay={delayFor(rowIndex, 0)}
rise={rise}
cfg={cfg}
reduceMotion={Boolean(reduceMotion)}
>
<span style={{ fontSize: 12.5, fontWeight: 550 }}>{row.source}</span>
</Cell>
<Cell
filled={filled}
barWidth="72%"
align="right"
delay={delayFor(rowIndex, 1)}
rise={rise}
cfg={cfg}
reduceMotion={Boolean(reduceMotion)}
>
<span style={{ fontSize: 12.5, fontVariantNumeric: "tabular-nums" }}>
{row.sessions}
</span>
</Cell>
<Cell
filled={filled}
barWidth="64%"
align="right"
delay={delayFor(rowIndex, 2)}
rise={rise}
cfg={cfg}
reduceMotion={Boolean(reduceMotion)}
>
<span
style={{
fontSize: 12,
fontWeight: 600,
fontVariantNumeric: "tabular-nums",
color: row.down ? DOWN : UP,
}}
>
{row.change}
</span>
</Cell>
</div>
))}
</div>
);
}
/** One cell holds both states at a fixed height, so the table never
* changes size as the placeholders are traded for values. */
function Cell({
filled,
barWidth,
align = "left",
delay,
rise,
cfg,
reduceMotion,
children,
}: {
filled: boolean;
barWidth: string;
align?: "left" | "right";
delay: number;
rise: number;
cfg: VariantConfig;
reduceMotion: boolean;
children: ReactNode;
}) {
return (
<span
style={{
position: "relative",
display: "block",
height: 17,
lineHeight: "17px",
}}
>
<motion.span
aria-hidden
initial={false}
animate={{ opacity: filled ? 0 : 1 }}
transition={{
duration: reduceMotion ? 0.12 : cfg.barSeconds,
delay: filled ? delay : 0,
ease: "easeOut",
}}
style={{
position: "absolute",
top: 5,
left: align === "right" ? undefined : 0,
right: align === "right" ? 0 : undefined,
width: barWidth,
height: 8,
borderRadius: 4,
background: tone(11),
}}
/>
<motion.span
initial={false}
animate={{ opacity: filled ? 1 : 0, y: filled ? 0 : rise }}
transition={{
duration: reduceMotion ? 0.16 : cfg.valueSeconds,
delay: filled ? delay + cfg.barSeconds * 0.5 : 0,
ease: "easeOut",
}}
style={{
position: "absolute",
inset: 0,
display: "block",
textAlign: align,
}}
>
{children}
</motion.span>
</span>
);
}About this pattern
Not a loading state — a deliberate offer to show the product working. Pressing the action clears each placeholder bar and rises the example value into its place, stepped down the table and across each row so the fill reads as a sweep rather than a flash. Every cell holds both states at a fixed height, so the table never changes size while it is being traded over, and a SAMPLE badge arrives last so nobody mistakes the figures for their own. Values are text: they translate and fade only, because a number that scales while it lands is a number that cannot be read.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Onboarding flow
A new grid arriving pre-filled with example records to edit over.
Related patterns
- Seed First ProjectThe blank panel steps aside and a starter project builds itself in its place, task by task.
- Goal PickerChosen goal tiles paint themselves and take a mark, and the continue action rises the moment the first one is picked.
- Invite Team SendEach address leaves the compose box and travels into a waiting seat, then marks itself sent.