A dependency-free SVG bar chart card whose bars grow in with a staggered spring-like ease, with nice-rounded gridlines, an axis, value labels and a hover/keyboard tooltip. Vertical or horizontal, sized to its container via ResizeObserver.
Preview
| Category | Value |
|---|---|
| Jan | $186k |
| Feb | $305k |
| Mar | $237k |
| Apr | $273k |
| May | $209k |
| Jun | $314k |
| Jul | $342k |
| Aug | $298k |
Customize
14 propsInstallation
Copies the component source and its CSS Module into your project.
npx motionfoundry add bar-chartWrites components/ui/bar-chart.tsx and components/styles/bar-chart.module.css.
Usage
Updates live as you change the controls — only props that differ from the defaults are included.
1import { BarChart } from "@motionfoundry/ui";2 3export function Example() {4 return (5 <BarChart />6 );7}Props
Accessibility
The SVG is aria-hidden; a visually hidden <table> with a caption, column headers and row headers exposes every label/value pair to assistive tech. The plot is a single focusable role="group" with an accessible name: Arrow keys (and Home/End) step through bars, showing the tooltip and announcing "Label: value" in a polite live region; Escape clears. Focus is shown with a 2px outline. Under prefers-reduced-motion the bars render at full size without the grow-in.
Toggle prefers-reduced-motion in your OS or browser devtools (Rendering → Emulate CSS media feature) to verify the reduced-motion state.
Source
1"use client";2 3import * as React from "react";4import styles from "../styles/bar-chart.module.css";5 6export interface BarChartProps {7 /** Category labels, one per bar */8 labels?: string[];9 /** Bar values (numbers, numeric strings, or a comma separated string), aligned with `labels` */10 values?: Array<number | string> | string;11 /** Bar direction */12 orientation?: "vertical" | "horizontal";13 /** Card title (also the chart's accessible name) */14 title?: string;15 /** Secondary line under the title */16 description?: string;17 /** Bar color */18 accentColor?: string;19 /** Text placed before every formatted value */20 valuePrefix?: string;21 /** Text placed after every formatted value */22 valueSuffix?: string;23 /** Show horizontal/vertical gridlines and the value axis */24 showGrid?: boolean;25 /** Show the value label at the end of each bar */26 showValues?: boolean;27 /** Corner radius of the bar ends in px */28 barRadius?: number;29 /** Fraction of each band left empty between bars (0–0.8) */30 barGap?: number;31 /** Delay between consecutive bars growing in, in ms */32 stagger?: number;33 /** Plot height in px */34 height?: number;35 /** Optional className merged onto the root */36 className?: string;37 /** Optional inline styles merged onto the root */38 style?: React.CSSProperties;39}40 41const DEFAULT_LABELS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug"];42const DEFAULT_VALUES = [186, 305, 237, 273, 209, 314, 342, 298];43 44function toNumbers(v: Array<number | string> | string): number[] {45 const arr = typeof v === "string" ? v.split(",") : v;46 return arr.map((x) => {47 const n = typeof x === "number" ? x : parseFloat(String(x).trim());48 return Number.isFinite(n) ? Math.max(0, n) : 0;49 });50}51 52function niceScale(max: number, count = 4) {53 if (max <= 0) return { top: 1, ticks: [0, 1] };54 const raw = max / count;55 const mag = Math.pow(10, Math.floor(Math.log10(raw)));56 const step = [1, 2, 2.5, 5, 10].map((s) => s * mag).find((s) => s >= raw) ?? raw;57 const top = Math.ceil(max / step) * step;58 const ticks: number[] = [];59 for (let t = 0; t <= top + step / 2; t += step) ticks.push(Math.round(t * 1000) / 1000);60 return { top, ticks };61}62 63function barPath(h: boolean, x: number, y: number, w: number, len: number, radius: number) {64 const r = Math.max(0, Math.min(radius, w / 2, len));65 if (h) return `M${x},${y}H${x + len - r}Q${x + len},${y} ${x + len},${y + r}V${y + w - r}Q${x + len},${y + w} ${x + len - r},${y + w}H${x}Z`;66 return `M${x},${y + len}V${y + r}Q${x},${y} ${x + r},${y}H${x + w - r}Q${x + w},${y} ${x + w},${y + r}V${y + len}Z`;67}68 69export function BarChart({70 labels = DEFAULT_LABELS,71 values = DEFAULT_VALUES,72 orientation = "vertical",73 title = "Monthly revenue",74 description = "January – August 2026",75 accentColor = "#6366f1",76 valuePrefix = "$",77 valueSuffix = "k",78 showGrid = true,79 showValues = true,80 barRadius = 6,81 barGap = 0.32,82 stagger = 60,83 height = 260,84 className = "",85 style,86}: BarChartProps) {87 const id = React.useId();88 const wrapRef = React.useRef<HTMLDivElement>(null);89 const [width, setWidth] = React.useState(600);90 const [active, setActive] = React.useState<number | null>(null);91 const [kbd, setKbd] = React.useState(false);92 93 React.useEffect(() => {94 const el = wrapRef.current;95 if (!el || typeof ResizeObserver === "undefined") return;96 const ro = new ResizeObserver(([entry]) => setWidth(Math.max(200, Math.round(entry.contentRect.width))));97 ro.observe(el);98 return () => ro.disconnect();99 }, []);100 101 const nums = toNumbers(values);102 const n = Math.min(labels.length, nums.length);103 const data = Array.from({ length: n }, (_, i) => ({ label: labels[i], value: nums[i] }));104 const fmt = (v: number) => `${valuePrefix}${v.toLocaleString("en-US")}${valueSuffix}`;105 const horizontal = orientation === "horizontal";106 const longest = data.reduce((m, d) => Math.max(m, d.label.length), 0);107 const m = horizontal108 ? { t: 8, r: showValues ? 52 : 16, b: showGrid ? 26 : 8, l: Math.min(140, 14 + longest * 7) }109 : { t: showValues ? 22 : 10, r: 8, b: 28, l: showGrid ? 44 : 8 };110 const iw = Math.max(10, width - m.l - m.r);111 const ih = Math.max(10, height - m.t - m.b);112 const { top, ticks } = niceScale(data.reduce((mx, d) => Math.max(mx, d.value), 0));113 const band = (horizontal ? ih : iw) / Math.max(1, n);114 const gap = Math.min(0.8, Math.max(0, barGap));115 const thick = band * (1 - gap);116 const span = horizontal ? iw : ih;117 118 const geo = data.map((d, i) => {119 const len = (d.value / top) * span;120 const off = i * band + (band - thick) / 2;121 return horizontal122 ? { x: m.l, y: m.t + off, len, cx: m.l + len, cy: m.t + off + thick / 2 }123 : { x: m.l + off, y: m.t + ih - len, len, cx: m.l + off + thick / 2, cy: m.t + ih - len };124 });125 126 const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {127 if (!n) return;128 const cur = active ?? -1;129 let next = -1;130 if (e.key === "ArrowRight" || e.key === "ArrowDown") next = Math.min(n - 1, cur + 1);131 else if (e.key === "ArrowLeft" || e.key === "ArrowUp") next = Math.max(0, cur - 1);132 else if (e.key === "Home") next = 0;133 else if (e.key === "End") next = n - 1;134 else if (e.key === "Escape") return setActive(null);135 if (next < 0) return;136 e.preventDefault();137 setKbd(true);138 setActive(next);139 };140 141 const tip = active !== null ? geo[active] : null;142 const tipData = active !== null ? data[active] : null;143 144 return (145 <figure146 className={`${styles.root} ${className}`.trim()}147 data-orientation={orientation}148 style={{ "--mf-bc-accent": accentColor, "--mf-bc-stagger": `${Math.max(0, stagger)}ms`, ...style } as React.CSSProperties}149 >150 {(title || description) && (151 <figcaption className={styles.head}>152 {title && <span id={`${id}-t`} className={styles.title}>{title}</span>}153 {description && <span className={styles.desc}>{description}</span>}154 </figcaption>155 )}156 <div157 ref={wrapRef}158 className={styles.plot}159 tabIndex={0}160 role="group"161 aria-label={`${title || "Bar chart"}. Use arrow keys to inspect bars.`}162 onKeyDown={onKeyDown}163 onBlur={() => kbd && setActive(null)}164 onPointerLeave={() => setActive(null)}165 >166 <svg width={width} height={height} className={styles.svg} aria-hidden="true">167 {showGrid &&168 ticks.map((t) => {169 const p = (t / top) * span;170 return horizontal ? (171 <g key={t}>172 <line className={styles.grid} x1={m.l + p} x2={m.l + p} y1={m.t} y2={m.t + ih} />173 <text className={styles.tick} x={m.l + p} y={height - 8} textAnchor="middle">{t.toLocaleString("en-US")}</text>174 </g>175 ) : (176 <g key={t}>177 <line className={styles.grid} x1={m.l} x2={m.l + iw} y1={m.t + ih - p} y2={m.t + ih - p} />178 <text className={styles.tick} x={m.l - 8} y={m.t + ih - p} textAnchor="end" dominantBaseline="middle">{t.toLocaleString("en-US")}</text>179 </g>180 );181 })}182 {horizontal ? (183 <line className={styles.axis} x1={m.l} x2={m.l} y1={m.t} y2={m.t + ih} />184 ) : (185 <line className={styles.axis} x1={m.l} x2={m.l + iw} y1={m.t + ih} y2={m.t + ih} />186 )}187 {data.map((d, i) => {188 const g = geo[i];189 return (190 <g key={`${d.label}-${i}`} style={{ "--i": i } as React.CSSProperties}>191 <rect192 className={styles.hit}193 x={horizontal ? m.l : m.l + i * band}194 y={horizontal ? m.t + i * band : m.t}195 width={horizontal ? iw : band}196 height={horizontal ? band : ih}197 onPointerEnter={() => {198 setKbd(false);199 setActive(i);200 }}201 />202 <path203 className={styles.bar}204 data-dim={active !== null && active !== i ? "" : undefined}205 d={barPath(horizontal, g.x, g.y, thick, g.len, barRadius)}206 />207 {showValues && (208 <text209 className={styles.value}210 x={horizontal ? g.cx + 6 : g.cx}211 y={horizontal ? g.cy : g.cy - 7}212 textAnchor={horizontal ? "start" : "middle"}213 dominantBaseline={horizontal ? "middle" : "auto"}214 >215 {fmt(d.value)}216 </text>217 )}218 <text219 className={styles.label}220 x={horizontal ? m.l - 8 : g.cx}221 y={horizontal ? g.cy : m.t + ih + 18}222 textAnchor={horizontal ? "end" : "middle"}223 dominantBaseline={horizontal ? "middle" : "auto"}224 >225 {d.label}226 </text>227 </g>228 );229 })}230 </svg>231 {tip && tipData && (232 <div233 className={styles.tooltip}234 data-side={horizontal ? "right" : "top"}235 style={{ left: tip.cx, top: tip.cy } as React.CSSProperties}236 aria-hidden="true"237 >238 <span className={styles.tipLabel}>{tipData.label}</span>239 <span className={styles.tipValue}>240 <i className={styles.swatch} />241 {fmt(tipData.value)}242 </span>243 </div>244 )}245 <span className={styles.srOnly} aria-live="polite">246 {kbd && tipData ? `${tipData.label}: ${fmt(tipData.value)}` : ""}247 </span>248 </div>249 <table className={styles.srOnly}>250 <caption>{title || "Bar chart data"}</caption>251 <thead>252 <tr>253 <th scope="col">Category</th>254 <th scope="col">Value</th>255 </tr>256 </thead>257 <tbody>258 {data.map((d, i) => (259 <tr key={`${d.label}-${i}`}>260 <th scope="row">{d.label}</th>261 <td>{fmt(d.value)}</td>262 </tr>263 ))}264 </tbody>265 </table>266 </figure>267 );268}