Chart Bars Grow
Bars rise out of the baseline in reading order, with the axis landing first so there is something to measure against.
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 { motion, useReducedMotion } from "motion/react";
/**
* Vibary · Chart Bars Grow
*
* Bars rise out of the baseline in reading order as the series lands.
* The stagger is the point: it walks the eye left to right through the
* data instead of dropping a finished chart on the page, and it gives
* the axis a moment to establish itself before anything is plotted
* against it.
*
* Self-contained: depends only on `motion` (react ships with your app).
* Surfaces are mixed from the inherited text color, so the card reads
* correctly on a light page and on a dark one.
* Works with zero props; pass `data` and `loaded` for real use.
* Requires the automatic JSX runtime (default since React 17).
*/
export type ChartBarsGrowProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Your series. Falls back to embedded sample data. */
data?: { label: string; value: number }[];
/** Drive this from your request state. Left undefined, the component
* plots itself after `revealAfterMs` so the file runs as-is. */
loaded?: boolean;
/** Only consulted while `loaded` is undefined. */
revealAfterMs?: number;
/** Card title. */
title?: string;
/** Line under the title. */
subtitle?: string;
/** Color of the tallest bar. A semantic highlight, so it stays literal. */
accent?: string;
/** Plot height in px, excluding the axis labels. */
plotHeight?: number;
/** Card width — px number or any CSS length. */
width?: number | string;
};
type VariantConfig = {
/** Seconds between one bar starting and the next. */
stagger: number;
/** Delay before the first bar, in seconds — the axis lands first. */
lead: number;
fadeSeconds: number;
spring: { type: "spring"; stiffness: number; damping: number };
};
// Quality rule: every spring here is over-damped. A bar that overshoots
// its value draws the wrong number for a few frames, which is a data
// error, not a flourish — so the bars arrive and stop. Variants change
// the pace of the sweep, never the settle.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// Nearly simultaneous. For a dashboard where six of these resolve at
// once and a long sweep would turn into a wave.
subtle: {
stagger: 0.03,
lead: 0.04,
fadeSeconds: 0.22,
spring: { type: "spring", stiffness: 340, damping: 40 },
},
// The all-purpose setting: a readable left-to-right sweep, settled
// well inside a second.
default: {
stagger: 0.06,
lead: 0.08,
fadeSeconds: 0.28,
spring: { type: "spring", stiffness: 260, damping: 34 },
},
// A slower walk through the series, for a single chart the page is
// built around.
playful: {
stagger: 0.09,
lead: 0.12,
fadeSeconds: 0.34,
spring: { type: "spring", stiffness: 200, damping: 30 },
},
};
const ACCENT = "#4C7DF0";
/** Theme-adaptive neutral: mixing the inherited text color with
* transparent gives bars, gridlines and borders that are correctly
* toned on a light page and on a dark one. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
const SAMPLE_DATA = [
{ label: "Mar", value: 42 },
{ label: "Apr", value: 58 },
{ label: "May", value: 51 },
{ label: "Jun", value: 76 },
{ label: "Jul", value: 64 },
{ label: "Aug", value: 93 },
{ label: "Sep", value: 71 },
];
export default function ChartBarsGrow({
variant = "default",
data = SAMPLE_DATA,
loaded,
revealAfterMs = 700,
title = "Tickets resolved",
subtitle = "Last 7 months",
accent = ACCENT,
plotHeight = 124,
width = 336,
}: ChartBarsGrowProps) {
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
const [selfLoaded, setSelfLoaded] = useState(false);
// Uncontrolled by default so the file runs on its own; the moment a
// caller passes `loaded`, this timer stays out of the way.
useEffect(() => {
if (loaded !== undefined) return;
const timer = setTimeout(() => setSelfLoaded(true), revealAfterMs);
return () => clearTimeout(timer);
}, [loaded, revealAfterMs]);
const isLoaded = loaded ?? selfLoaded;
const peak = Math.max(...data.map((point) => point.value), 1);
// Reduced motion keeps the reading order — bars still resolve left to
// right — and drops the growth: each bar is simply there, at its full
// height, when its turn comes.
const stagger = reduceMotion ? cfg.stagger * 0.5 : cfg.stagger;
return (
<div
aria-busy={!isLoaded}
style={{
width,
padding: 18,
borderRadius: 16,
border: `1px solid ${tone(12)}`,
background: tone(4),
}}
>
<div style={{ marginBottom: 16 }}>
<div style={{ fontSize: 13.5, fontWeight: 600, lineHeight: 1.3 }}>
{title}
</div>
<div style={{ fontSize: 11.5, opacity: 0.52, marginTop: 3 }}>
{subtitle}
</div>
</div>
<div style={{ position: "relative", height: plotHeight }}>
{/* Gridlines arrive before the data so the bars have something
to be measured against from the first frame they exist. */}
{[0, 0.5, 1].map((fraction) => (
<motion.div
key={fraction}
aria-hidden
initial={{ opacity: 0 }}
animate={{ opacity: isLoaded ? 1 : 0 }}
transition={{ duration: 0.24, ease: "easeOut" }}
style={{
position: "absolute",
left: 0,
right: 0,
bottom: fraction * plotHeight,
height: 1,
background: fraction === 0 ? tone(18) : tone(8),
}}
/>
))}
<div
style={{
position: "absolute",
inset: 0,
display: "flex",
alignItems: "flex-end",
gap: 8,
}}
>
{data.map((point, index) => {
const target = Math.round((point.value / peak) * plotHeight);
const delay = cfg.lead + index * stagger;
const isPeak = point.value === peak;
return (
<div
key={point.label}
style={{
flex: 1,
display: "flex",
flexDirection: "column",
justifyContent: "flex-end",
height: "100%",
}}
>
{/* Height, not scaleY: a scaled bar squashes its own
corner radius and its top edge lands off the pixel
grid. This is a genuine size change, so it is sized. */}
<motion.div
initial={{ height: reduceMotion ? target : 0 }}
animate={{ height: isLoaded ? target : reduceMotion ? target : 0 }}
transition={{ ...cfg.spring, delay }}
style={{
width: "100%",
borderRadius: "4px 4px 2px 2px",
background: isPeak ? accent : tone(20),
}}
/>
</div>
);
})}
</div>
</div>
{/* Axis labels are type: they fade with their bar and never move
or resize while the chart is building. */}
<div style={{ display: "flex", gap: 8, marginTop: 8 }}>
{data.map((point, index) => (
<motion.span
key={point.label}
initial={{ opacity: 0 }}
animate={{ opacity: isLoaded ? 0.5 : 0 }}
transition={{
duration: cfg.fadeSeconds,
ease: "easeOut",
delay: cfg.lead + index * stagger,
}}
style={{
flex: 1,
textAlign: "center",
fontSize: 10.5,
letterSpacing: 0.2,
}}
>
{point.label}
</motion.span>
))}
</div>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: isLoaded ? 1 : 0 }}
transition={{
duration: cfg.fadeSeconds,
ease: "easeOut",
delay: cfg.lead + data.length * stagger,
}}
style={{
marginTop: 14,
paddingTop: 12,
borderTop: `1px solid ${tone(10)}`,
display: "flex",
alignItems: "baseline",
justifyContent: "space-between",
fontSize: 12,
}}
>
<span style={{ opacity: 0.55 }}>Peak month</span>
<span style={{ fontWeight: 600, color: accent }}>
{data.find((point) => point.value === peak)?.label ?? "—"} · {peak}
</span>
</motion.div>
</div>
);
}About this pattern
How a chart should arrive. The gridlines and baseline fade in a beat ahead of the data, then each column grows from zero to its value about sixty milliseconds after the one to its left, walking the eye through the series instead of dropping a finished plot on the page. Two decisions carry the quality here. The bars are sized rather than scaled, because a scaled bar squashes its own corner radius and lands its top edge off the pixel grid. And every spring is over-damped: a column that overshoots its value draws a number the data does not contain, which is a reporting error rather than a flourish.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Dashboard
Volume columns resolve out of the baseline once the period's figures are in.
Related patterns
- Dashboard Tiles CascadeMetric tiles resolve corner to corner in a diagonal wave instead of arriving as one slab.
- Cache Hit InstantCached content gets no entrance at all; only the values that actually changed animate.
- Carousel Preload SlideThe upcoming frame resolves from placeholder to artwork a beat before the track moves to it.