Bar Chart Grow
Bars rise from the baseline one after another as the chart scrolls into view, with each value appearing on top.
Create a React bar chart whose bars grow when it scrolls into view. Render 7 bars for Mon to Sun as divs (or SVG rects) with height set from the data as a percentage of the max value, and transform-origin bottom. Start every bar at transform: scaleY(0); when an IntersectionObserver (threshold 0.5, once) fires, set data-in on the chart and transition each bar to scaleY(1) over 700ms with cubic-bezier(0.16, 1, 0.3, 1), delayed by index × 70ms. Fade each value label in 300ms after its bar. Highlight the highest bar with the accent colour. Keep the data readable without the animation: a visually hidden table or aria-label on each bar with the day and value. Title: "Orders per day". Add a "Replay" button. Under prefers-reduced-motion, show the bars at full height immediately.
Source
'use client'
import { useEffect, useRef, useState } from 'react'
import s from './demo.module.css'
const DATA = [42, 58, 51, 73, 96, 64, 38]
export default function BarChartGrow({ labels }: { labels: Record<string, string> }) {
const days = labels.days.split('|')
const max = Math.max(...DATA)
const ref = useRef<HTMLDivElement>(null)
const [run, setRun] = useState(0)
useEffect(() => {
const el = ref.current
if (!el) return
const io = new IntersectionObserver(
([e]) => {
if (!e.isIntersecting) return
el.dataset.in = ''
io.disconnect()
},
{ threshold: 0.5 },
)
io.observe(el)
return () => io.disconnect()
}, [run])
return (
<div className={s.wrap}>
<div className={s.head}>
<span className={s.title}>{labels.title}</span>
<button type="button" className={s.button} onClick={() => setRun(run + 1)}>
{labels.replay}
</button>
</div>
<div key={run} ref={ref} className={s.chart} role="list">
{DATA.map((v, i) => (
<div key={days[i]} className={s.col} role="listitem" aria-label={`${days[i]}: ${v}`} style={{ '--i': i } as React.CSSProperties}>
<span className={s.value} aria-hidden="true">
{v}
</span>
<span className={s.bar} data-max={v === max || undefined} style={{ height: `calc((100% - 34px) * ${v / max})` }} />
<span className={s.day} aria-hidden="true">
{days[i]}
</span>
</div>
))}
</div>
</div>
)
}
.wrap { display: grid; gap: 12px; width: min(300px, 100%); }
.head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.title { font: 600 var(--text-sm) / 1 var(--font-body); color: var(--stage-ink); }
.chart {
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
gap: 8px;
height: 140px;
padding-bottom: 2px;
border-bottom: 1px solid var(--stage-rule);
}
.col { display: flex; flex-direction: column; justify-content: flex-end; align-items: center; gap: 4px; min-width: 0; }
.bar {
width: 100%;
border-radius: 4px 4px 0 0;
background: var(--stage-muted);
transform: scaleY(0);
transform-origin: bottom;
}
.bar[data-max] { background: var(--stage-accent); }
.value, .day {
font: 500 0.625rem / 1 var(--font-mono);
color: var(--stage-muted);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.value { opacity: 0; color: var(--stage-ink); }
.day { margin-top: 4px; }
.chart[data-in] .bar {
transform: scaleY(1);
transition: transform 700ms var(--ease-out) calc(var(--i) * 70ms);
}
.chart[data-in] .value {
opacity: 1;
transition: opacity 300ms var(--ease-out) calc(var(--i) * 70ms + 300ms);
}
.button {
height: 28px;
padding: 0 10px;
border: 1px solid var(--stage-rule);
border-radius: 8px;
background: var(--stage-surface);
color: var(--stage-ink);
font: 500 var(--text-xs) / 1 var(--font-body);
white-space: nowrap;
cursor: pointer;
}
.button:hover { border-color: var(--stage-accent); }
.button:focus-visible { outline: 2px solid var(--color-focus); outline-offset: 2px; }
@media (prefers-reduced-motion: reduce) {
.bar { transform: none; }
.value { opacity: 1; }
.chart[data-in] .bar, .chart[data-in] .value { transition: none; }
}