UIData Displayno dependencies

BarChart

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

Monthly revenueJanuary – August 2026
Monthly revenue
CategoryValue
Jan$186k
Feb$305k
Mar$237k
Apr$273k
May$209k
Jun$314k
Jul$342k
Aug$298k

Customize

14 props
6
0.32
60
260

Installation

Copies the component source and its CSS Module into your project.

npx motionfoundry add bar-chart

Writes 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.

example.tsx
1import { BarChart } from "@motionfoundry/ui";2 3export function Example() {4  return (5    <BarChart />6  );7}

Props

PropTypeDefault
labels

Category labels, one per bar.

string[]['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug']
values

Bar values aligned with labels; numeric strings and comma separated strings are parsed. Negative or invalid values render as 0.

Array<number | string> | string[186, 305, 237, 273, 209, 314, 342, 298]
orientation

Column chart or horizontal bar chart.

'vertical' | 'horizontal''vertical'
title

Card title; also names the chart and the fallback table.

string'Monthly revenue'
description

Secondary line under the title.

string'January – August 2026'
accentColor

Bar and tooltip swatch color.

string'#6366f1'
valuePrefix

Text placed before formatted values.

string'$'
valueSuffix

Text placed after formatted values.

string'k'
showGrid

Show dashed gridlines and value-axis ticks.

booleantrue
showValues

Show a value label at the end of each bar.

booleantrue
barRadius

Corner radius of the bar ends in px.

number6
barGap

Fraction of each band left empty between bars (0–0.8).

number0.32
stagger

Delay between consecutive bars growing in, in ms.

number60
height

Plot height in px (width follows the container).

number260
className

Additional class merged onto the root element.

string''
style

Inline styles merged onto the root element.

React.CSSPropertiesundefined

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

components/ui/bar-chart.tsx
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}