Bar chartDesign system
Product

Bar chart

Stacked bar chart with animated updates, series selection, and a tooltip.

npx context-ds add bar-chart
In the product

Usage

Use it for

Usage over time by status, key, path or API.

Avoid

  • 3D or gradient bars
  • Redrawing the bars on every change
  • Tooltips that ease after the pointer

Dependencies: surface

Example

examples/bar-chart.tsx
"use client";import { BarChart, type ChartBucket, useSeriesSelection } from "@/components/product/bar-chart";type K = "200" | "404" | "429";const SERIES = [  { key: "200" as const, label: "200 OK", color: "var(--ds-color-chart-200)" },  { key: "404" as const, label: "404 Not found", color: "var(--ds-color-chart-404)" },  { key: "429" as const, label: "429 Rate limited", color: "var(--ds-color-chart-rate-limited)" },];const BUCKETS: ChartBucket<K>[] = Array.from({ length: 30 }, (_, i) => {  const ok = Math.round(600 + Math.sin(i / 2) * 250 + i * 30);  const missing = Math.round(40 + Math.cos(i / 3) * 20);  const limited = i % 6 === 0 ? 35 : 0;  return { label: new Date(2026, 8, 18 + i).toLocaleDateString("en-US", { month: "short", day: "numeric" }), total: ok + missing + limited, values: { "200": ok, "404": missing, "429": limited } };});export function Product() {  const { fadedKeys, selectSeries } = useSeriesSelection(SERIES);  return (    <section className="flex h-64 w-full max-w-2xl flex-col rounded-card bg-surface p-2 shadow-card">      <BarChart buckets={BUCKETS} series={SERIES} unit="calls" label="API calls by status, last 30 days" fadedKeys={fadedKeys} onSeriesSelect={selectSeries} />    </section>  );}

Source

components/product/bar-chart.tsx
"use client";import * as React from "react";import { createPortal } from "react-dom";import { usePortalContainer } from "@/lib/surface";import { cn } from "@/lib/utils";// Usage over time as stacked bars, one a bucket, each series a band in its color, over a value axis and three dates.// The bars grow up from the axis the first time only, left to right; when the data changes but the number of bars// doesn't (a new metric, breakdown or filter), they glide from what's on screen to the new values (250ms), and a new// number of bars crossfades in. The legend is the series picker: a click picks one series out, shift- or cmd-click// adds or removes it, and the rest fade back. Hovering a bucket shows its breakdown in the shared chart tooltip.export type ChartBucket<K extends string = string> = { label: string; total: number; values: Partial<Record<K, number>> };export type ChartSeries<K extends string = string> = { key: K; label: string; color: string };export type SeriesSelectMode = "only" | "toggle";export const Y_INTERVALS = 4;const PAD = { top: 12, right: 4, bottom: 26, left: 36 };const BAR_SHARE = 0.72;const BAR_MIN = 3;const BAR_MAX = 28;const BAR_RADIUS = 3;const MORPH_MS = 250;const DRAW_IN_MS = 800;const DRAW_IN_STAGGER_MS = 300;// The brand's ease-out, cubic-bezier(0.22, 1, 0.36, 1), which is ease-out quint.const easeOut = (t: number) => 1 - (1 - t) ** 5;type Shown<K extends string> = { buckets: ChartBucket<K>[]; top: number; generation: number };/** The axis top: a round step (1, 2, 2.5 or 5 times a power of ten, at least 1) times the number of intervals. */export function niceMax(peak: number) {  const raw = peak / Y_INTERVALS;  if (raw <= 1) return Y_INTERVALS;  const magnitude = 10 ** Math.floor(Math.log10(raw));  const step = [1, 2, 2.5, 5, 10].map((m) => m * magnitude).find((c) => c >= raw) ?? raw;  return step * Y_INTERVALS;}/** Measures the chart's box, so bars, gaps and text are drawn in real pixels however wide the card is. */export function useChartSize() {  const ref = React.useRef<HTMLDivElement>(null);  const [size, setSize] = React.useState({ width: 0, height: 0 });  React.useLayoutEffect(() => {    const el = ref.current;    if (!el) return;    const measure = () => setSize({ width: el.clientWidth, height: el.clientHeight });    measure();    const observer = new ResizeObserver(measure);    observer.observe(el);    return () => observer.disconnect();  }, []);  return { ref, size };}/** Plain click: that series alone (again: all). Shift, cmd or ctrl: add or remove it. */export function seriesSelectModeForClick(event: { shiftKey: boolean; metaKey: boolean; ctrlKey: boolean }): SeriesSelectMode {  return event.shiftKey || event.metaKey || event.ctrlKey ? "toggle" : "only";}/** The legend's selection: which series are picked out, and the set of the others to fade. */export function useSeriesSelection<K extends string>(series: { key: K }[]) {  const [selected, setSelected] = React.useState<K[]>([]);  // Selections survive range and metric changes, so keys whose series are gone drop out instead of leaving an empty chart.  const active = React.useMemo(() => {    const keys = new Set(series.map((s) => s.key));    return selected.filter((k) => keys.has(k));  }, [selected, series]);  const selectSeries = React.useCallback(    (key: K, mode: SeriesSelectMode) =>      setSelected(mode === "toggle" ? (active.includes(key) ? active.filter((k) => k !== key) : [...active, key]) : active.length === 1 && active[0] === key ? [] : [key]),    [active],  );  const fadedKeys = React.useMemo(() => {    const picked = new Set(active);    return new Set(active.length === 0 ? [] : series.map((s) => s.key).filter((k) => !picked.has(k)));  }, [active, series]);  return { fadedKeys, selectSeries };}const same = <K extends string>(a: ChartBucket<K>, b: ChartBucket<K>) =>  a.total === b.total && Object.keys({ ...a.values, ...b.values }).every((k) => (a.values[k as K] ?? 0) === (b.values[k as K] ?? 0));function mix<K extends string>(from: ChartBucket<K>, to: ChartBucket<K>, p: number): ChartBucket<K> {  const values: Partial<Record<K, number>> = {};  for (const k of Object.keys({ ...from.values, ...to.values }) as K[]) values[k] = (from.values[k] ?? 0) + ((to.values[k] ?? 0) - (from.values[k] ?? 0)) * p;  return { label: to.label, values, total: from.total + (to.total - from.total) * p };}// An interrupted glide picks up from wherever it got to; a new number of bars has nothing to glide from, so it swaps.function useMorphedBuckets<K extends string>(buckets: ChartBucket<K>[], top: number): Shown<K> {  const [shown, setShown] = React.useState<Shown<K>>({ buckets, top, generation: 0 });  const ref = React.useRef(shown);  React.useLayoutEffect(() => {    const from = ref.current;    const show = (next: Shown<K>) => {      ref.current = next;      setShown(next);    };    const unchanged = from.top === top && from.buckets.length === buckets.length && from.buckets.every((b, i) => same(b, buckets[i]));    const canMorph = from.buckets.length === buckets.length && !window.matchMedia("(prefers-reduced-motion: reduce)").matches;    if (unchanged || !canMorph) {      show({ buckets, top, generation: from.generation + (from.buckets.length === buckets.length ? 0 : 1) });      return;    }    const start = performance.now();    let frame = requestAnimationFrame(function step(now) {      const p = Math.min(1, (now - start) / MORPH_MS);      const e = easeOut(p);      show(p === 1 ? { buckets, top, generation: from.generation } : { buckets: buckets.map((b, i) => mix(from.buckets[i], b, e)), top: from.top + (top - from.top) * e, generation: from.generation });      if (p < 1) frame = requestAnimationFrame(step);    });    return () => cancelAnimationFrame(frame);  }, [buckets, top]);  return shown;}function barOutline(x: number, barTop: number, barWidth: number, baseline: number) {  const ry = Math.min(BAR_RADIUS, baseline - barTop);  const rx = Math.min(BAR_RADIUS, barWidth / 2);  return `M${x},${baseline}V${barTop + ry}q0,-${ry} ${rx},-${ry}H${x + barWidth - rx}q${rx},0 ${rx},${ry}V${baseline}Z`;}export function BarChart<K extends string>({  buckets,  series,  unit,  label,  fadedKeys,  onSeriesSelect,  legendAside,  className,}: {  buckets: ChartBucket<K>[];  series: ChartSeries<K>[];  /** What the numbers count, plural ("calls"); the tooltip drops the s for one. */  unit: string;  /** The chart's accessible name. */  label: string;  /** Series left out of the picked set: they fade back. */  fadedKeys?: Set<K>;  /** Makes the legend the series picker (see useSeriesSelection). */  onSeriesSelect?: (key: K, mode: SeriesSelectMode) => void;  /** A control for the right end of the legend row, such as the card's metric switch. */  legendAside?: React.ReactNode;  className?: string;}) {  const { ref, size } = useChartSize();  // Plain characters only, since the id names SVG clip paths referenced by url(#...).  const id = `chart${React.useId().replace(/[^a-zA-Z0-9]/g, "")}`;  const [activeIndex, setActiveIndex] = React.useState<number | null>(null);  const [cursor, setCursor] = React.useState<ChartCursor>();  // The bars grow in from the axis the first time only; after that, changes glide or crossfade instead of redrawing.  const [drawnIn, setDrawnIn] = React.useState(false);  React.useEffect(() => {    const t = setTimeout(() => setDrawnIn(true), DRAW_IN_MS);    return () => clearTimeout(t);  }, []);  const plotWidth = Math.max(0, size.width - PAD.left - PAD.right);  const plotHeight = Math.max(0, size.height - PAD.top - PAD.bottom);  // The axis labels name the new scale straight away; the bars get there through the glide.  const top = niceMax(Math.max(0, ...buckets.map((b) => b.total)));  const shown = useMorphedBuckets(buckets, top);  const slot = buckets.length ? plotWidth / buckets.length : 0;  const barWidth = Math.min(BAR_MAX, Math.max(BAR_MIN, slot * BAR_SHARE));  const centerAt = (i: number) => PAD.left + slot * (i + 0.5);  const baseline = PAD.top + plotHeight;  const yAt = (v: number) => baseline - (v / shown.top) * plotHeight;  const valueY = (v: number) => baseline - (v / top) * plotHeight;  const labelIndexes = [...new Set([0, Math.floor((buckets.length - 1) / 2), buckets.length - 1])].filter((i) => i >= 0);  const ready = size.width > 0 && size.height > 0;  const active = activeIndex === null ? null : buckets[activeIndex];  return (    <div data-slot="bar-chart" className={cn("flex flex-1 flex-col gap-2", className)}>      <div        ref={ref}        role="img"        aria-label={label}        onMouseMove={(e) => {          if (!slot) return;          const x = e.clientX - e.currentTarget.getBoundingClientRect().left - PAD.left;          setCursor({ x: e.clientX, y: e.clientY });          setActiveIndex(Math.min(buckets.length - 1, Math.max(0, Math.floor(x / slot))));        }}        onMouseLeave={() => setActiveIndex(null)}        className="relative min-h-28 flex-1"      >        {ready && (          <svg width={size.width} height={size.height} className="absolute inset-0 overflow-visible" aria-hidden>            {Array.from({ length: Y_INTERVALS + 1 }, (_, s) => {              const v = (top * s) / Y_INTERVALS;              return (                <g key={s}>                  <line x1={PAD.left} x2={size.width - PAD.right} y1={valueY(v)} y2={valueY(v)} stroke="var(--ds-color-border-subtle)" />                  <text x={PAD.left - 8} y={valueY(v)} dy="0.32em" textAnchor="end" className="fill-fg-subtle text-axis tabular-nums">                    {v.toLocaleString("en-US", { notation: "compact", maximumFractionDigits: 1 })}                  </text>                </g>              );            })}            <g key={shown.generation} className={cn(shown.generation > 0 && "animate-fade-in")}>              {shown.buckets.map((bucket, i) => {                const x = centerAt(i) - barWidth / 2;                let below = 0;                const bands = series.map((s) => {                  const v = bucket.values[s.key] ?? 0;                  const band = { s, y: yAt(below + v), height: (v / shown.top) * plotHeight };                  below += v;                  return band;                });                // The bar's top is the top of its drawn bands, which a breakdown switch can leave short of the total mid-glide.                const barTop = yAt(below);                return (                  <g key={i}>                    <rect x={x - 3} y={PAD.top - 4} width={barWidth + 6} height={plotHeight + 8} rx={3} fill="var(--ds-color-wash-hover)" opacity={activeIndex === i ? 1 : 0} className="transition-opacity duration-150" />                    {baseline - barTop > 0 && (                      <g                        className={cn("origin-bottom [transform-box:fill-box] motion-reduce:animate-none", !drawnIn && "animate-chart-bar")}                        style={drawnIn ? undefined : { animationDelay: `${Math.round((i / Math.max(1, shown.buckets.length - 1)) * DRAW_IN_STAGGER_MS)}ms` }}                      >                        <clipPath id={`${id}-${i}`}>                          <path d={barOutline(x, barTop, barWidth, baseline)} />                        </clipPath>                        <g clipPath={`url(#${id}-${i})`}>                          {bands.map(({ s, y, height }) =>                            height > 0 ? <rect key={s.key} x={x} y={y} width={barWidth} height={height} fill={s.color} opacity={fadedKeys?.has(s.key) ? 0.18 : 1} className="transition-opacity duration-200" /> : null,                          )}                        </g>                      </g>                    )}                  </g>                );              })}            </g>            {/* The first date starts at its column's left edge and the last ends at its right one, so a long label stays inside the card. */}            {labelIndexes.map((i) => {              const first = i === 0 && buckets.length > 1;              const last = i === buckets.length - 1 && buckets.length > 1;              return (                <text key={i} x={first ? centerAt(i) - barWidth / 2 : last ? centerAt(i) + barWidth / 2 : centerAt(i)} y={size.height - 6} textAnchor={first ? "start" : last ? "end" : "middle"} className="fill-fg-subtle text-axis">                  {buckets[i].label}                </text>              );            })}          </svg>        )}        {active && cursor && (          <ChartTooltip cursor={cursor} title={active.label} total={active.total} unit={unit} rows={series.map((s) => ({ key: s.key, label: s.label, color: s.color, value: active.values[s.key] ?? 0 }))} />        )}      </div>      {/* One line at the switch's height whether or not a switch sits beside it, so the card's bottom never moves; a          legend too long for the line scrolls sideways instead of wrapping. */}      <div className="flex h-7 items-center gap-3">        <ul className="flex min-w-0 flex-1 items-center gap-x-2.5 overflow-x-auto pl-1 text-axis whitespace-nowrap text-fg-muted [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">          {series.map((s) => {            const faded = fadedKeys?.has(s.key) ?? false;            const content = (              <>                <span className="size-1.5 rounded-pill" style={{ background: s.color }} aria-hidden />                {s.label}              </>            );            return (              <li key={s.key} className={cn("shrink-0 transition-opacity duration-200", faded && "opacity-40")}>                {onSeriesSelect ? (                  <button type="button" aria-pressed={Boolean(fadedKeys?.size) && !faded} onClick={(e) => onSeriesSelect(s.key, seriesSelectModeForClick(e))} className="flex items-center gap-1 transition-colors duration-150 hover:text-fg">                    {content}                  </button>                ) : (                  <span className="flex items-center gap-1">{content}</span>                )}              </li>            );          })}        </ul>        {legendAside}      </div>    </div>  );}export type ChartCursor = { x: number; y: number };export type ChartTooltipRow = { key: string; label: string; color?: string; value: number };const CURSOR_GAP = 14;const EDGE_GAP = 8;const formatShare = (share: number) => (share > 0 && share < 0.01 ? "<1%" : `${Math.round(share * 100)}%`);/** * The one hover card every chart shares: small, pinned just off the pointer (below and to the right, flipping wherever * it would run past the window), following it without easing since it's read while moving, and portaled to the top * layer so no card or dialog around the chart can clip it. Rows with nothing in them are left out. */export function ChartTooltip({  cursor,  title,  total,  unit,  share,  rows = [],}: {  /** The pointer, in viewport coordinates (clientX, clientY). */  cursor: ChartCursor;  title: React.ReactNode;  total: number;  unit: string;  /** The total's share of a larger whole, for a chart where the hovered thing is one part. */  share?: number;  rows?: ChartTooltipRow[];}) {  const ref = React.useRef<HTMLDivElement>(null);  const container = usePortalContainer();  const [mounted, setMounted] = React.useState(false);  React.useEffect(() => setMounted(true), []);  React.useLayoutEffect(() => {    const card = ref.current;    if (!card) return;    const { width, height } = card.getBoundingClientRect();    const fitsRight = cursor.x + CURSOR_GAP + width <= window.innerWidth - EDGE_GAP;    const fitsBelow = cursor.y + CURSOR_GAP + height <= window.innerHeight - EDGE_GAP;    card.style.left = `${Math.max(EDGE_GAP, fitsRight ? cursor.x + CURSOR_GAP : cursor.x - CURSOR_GAP - width)}px`;    card.style.top = `${Math.max(EDGE_GAP, fitsBelow ? cursor.y + CURSOR_GAP : cursor.y - CURSOR_GAP - height)}px`;  });  if (!mounted) return null;  const shownRows = rows.filter((r) => r.value > 0);  return createPortal(    <div ref={ref} role="tooltip" data-slot="chart-tooltip" className="pointer-events-none fixed top-0 left-0 z-60 w-max min-w-40 rounded-control bg-surface px-2.5 py-2 text-caption shadow-floating">      <div className="flex items-baseline justify-between gap-5">        <span className="flex items-center gap-1.5 font-medium text-fg">{title}</span>        <span className="text-fg-muted">          <span className="font-medium text-fg tabular-nums">{total.toLocaleString("en-US")}</span> {total === 1 ? unit.replace(/s$/, "") : unit}          {share !== undefined && <span className="ml-2 text-fg-subtle tabular-nums">{formatShare(share)}</span>}        </span>      </div>      {shownRows.length > 0 && (        <ul className="mt-1.5 flex flex-col gap-1 border-t border-line-subtle pt-1.5">          {shownRows.map((r) => (            <li key={r.key} className="flex items-center gap-2">              <span className="size-1.5 shrink-0 rounded-pill bg-brand" style={r.color ? { background: r.color } : undefined} aria-hidden />              <span className="flex-1 text-fg-muted">{r.label}</span>              <span className="font-medium tabular-nums">{r.value.toLocaleString("en-US")}</span>              <span className="w-8 text-right text-fg-subtle tabular-nums">{formatShare(total === 0 ? 0 : r.value / total)}</span>            </li>          ))}        </ul>      )}    </div>,    container ?? document.body,  );}

Reference: context-dashboard c0ddf7a src/components/overview/bar-chart.tsx; src/components/overview/chart-tooltip.tsx.