柱状图生长
图表滚动进入视野时,柱子从基线依次升起,数值显示在柱顶。
每日订单
用 React 制作一个滚动进入视野时柱子会生长的柱状图。用 div(或 SVG rect)渲染周一到周日的 7 根柱子,高度按数据占最大值的百分比设置,transform-origin 为底部。所有柱子初始为 transform: scaleY(0);当 IntersectionObserver(threshold 0.5,只触发一次)触发时,在图表上设置 data-in,让每根柱子在 700ms 内过渡到 scaleY(1),缓动为 cubic-bezier(0.16, 1, 0.3, 1),延迟为序号 × 70ms。每个数值标签在对应柱子出现 300ms 后淡入。最高的柱子使用强调色。即使没有动画也要能读懂数据:提供一个视觉隐藏的表格,或在每根柱子上设置包含日期和数值的 aria-label。标题为“每日订单”。添加一个“重播”按钮。在 prefers-reduced-motion 下直接显示完整高度的柱子。
源代码
'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; }
}