← All components

ColumnChart

Grouped columns for a few short series, rising from the axis a period at a time.

Signups and Last year by period050100W1W2W3W4W5W6

The idea

Two or three series side by side per period. The emphasis series is filled with the value scale over its own range; the comparison stays flat. Pointing at a period dims the rest and names every series at that point, so the reading is always one period, whole.

Install

terminal1 line
npm i mihirmodi-ui motion

The tokens, the theme script and the routing provider are a one-time job — see Setup. Or skip the package entirely and take the source at the foot of this page.

Usage

ColumnChart — usage9 lines
import { ColumnChart } from "mihirmodi-ui";

<ColumnChart
  labels={["W1", "W2", "W3", "W4"]}
  series={[
    { name: "Signups", values: [42, 51, 47, 63], emphasis: true },
    { name: "Last year", values: [38, 40, 45, 44] },
  ]}
/>

Props

PropNotes
labelsstring[]One per group.
series{ name, values, emphasis? }[]Keep it to two or three; past that, a line reads better.
heightnumberThe chart scales to its width at this height.
digits / formatnumber / (v) => stringAxis and tooltip figures.
good"high" | "low"Flip the scale.

Source

Exactly what the package ships — this is read out of the source at build time, not a copy. Take it and adapt it if you would rather not add a dependency.

packages/ui/src/components/data/ColumnChart.tsx109 lines
"use client";

import { useId, useState } from "react";
import { ChartTip, ScaleGradient } from "./ScaleGradient.js";
import { CONTEXT, GRID, formatter, niceMax, type Format, type Good } from "../../lib/scale.js";
import { useDrawIn } from "../../lib/useDrawIn.js";

export type ColumnSeries = { name: string; values: number[]; emphasis?: boolean };

export type ColumnChartProps = {
  labels: string[];
  series: ColumnSeries[];
  height?: number;
  digits?: number;
  format?: Format;
  good?: Good;
};

/**
 * Grouped columns for a few short series, side by side per period.
 *
 * The emphasis series is filled with the value scale over its own range; the
 * comparison stays in the context colour. Pointing at a period dims the rest
 * and names every series at that point. The columns rise from the axis the
 * first time the chart is seen, a period at a time.
 */
export function ColumnChart({ labels, series, height = 180, digits = 0, format, good = "high" }: ColumnChartProps) {
  const id = useId().replace(/:/g, "");
  const f = formatter(digits, format);
  const { ref, drawn, animate } = useDrawIn<HTMLDivElement>();
  const [hover, setHover] = useState<{ i: number; left: number; top: number } | null>(null);
  const W = 600, H = height, padL = 36, padR = 8, padT = 12, padB = 26;
  const max = niceMax(Math.max(...series.flatMap((s) => s.values)) * 1.1);
  const n = labels.length;
  const band = (W - padL - padR) / n;
  const barW = Math.min(24, (band - 12) / series.length - 2);
  const y = (v: number) => padT + (H - padT - padB) * (1 - v / max);
  const ticks = [0, 0.5, 1].map((t) => t * max);
  const emph = series.find((s) => s.emphasis) ?? series[0];
  const eMin = Math.min(...emph.values), eMax = Math.max(...emph.values);

  return (
    <div ref={ref} className="relative" onMouseLeave={() => setHover(null)}>
      <svg viewBox={`0 0 ${W} ${H}`} className="w-full" style={{ height }} role="img" aria-labelledby={`${id}t`}>
        <title id={`${id}t`}>{`${series.map((s) => s.name).join(" and ")} by period`}</title>
        <defs>
          <ScaleGradient id={`${id}g`} y0={y(eMin)} y1={y(eMax)} good={good} />
        </defs>
        {ticks.map((t) => (
          <g key={t}>
            <line x1={padL} x2={W - padR} y1={y(t)} y2={y(t)} style={{ stroke: GRID }} strokeWidth="1" />
            <text x={padL - 8} y={y(t) + 3.5} textAnchor="end" fontSize="11" style={{ fill: "var(--color-faint)" }}>
              {f(t)}
            </text>
          </g>
        ))}
        {labels.map((l, i) => {
          const cx = padL + band * i + band / 2;
          const groupW = series.length * barW + (series.length - 1) * 2;
          return (
            <g
              key={l}
              onMouseEnter={(e) => {
                const rect = (e.currentTarget.ownerSVGElement as SVGSVGElement).getBoundingClientRect();
                setHover({ i, left: (cx / W) * rect.width, top: (y(Math.max(...series.map((s) => s.values[i]))) / H) * rect.height });
              }}
            >
              <rect x={padL + band * i} y={padT} width={band} height={H - padT - padB} fill="transparent" />
              {series.map((s, k) => {
                const v = s.values[i];
                const bx = cx - groupW / 2 + k * (barW + 2);
                const h = y(0) - y(v);
                return (
                  <path
                    key={s.name}
                    d={`M${bx},${y(0)} v${-(h - 4)} a4,4 0 0 1 4,-4 h${barW - 8} a4,4 0 0 1 4,4 v${h - 4} z`}
                    fill={s === emph ? `url(#${id}g)` : undefined}
                    style={{
                      ...(s === emph ? {} : { fill: CONTEXT }),
                      opacity: hover === null || hover.i === i ? 1 : 0.55,
                      transformBox: "fill-box",
                      transformOrigin: "bottom",
                      transform: drawn ? "scaleY(1)" : "scaleY(0)",
                      transition: animate ? `transform 650ms cubic-bezier(0.16,1,0.3,1) ${i * 45}ms, opacity 200ms` : "opacity 200ms",
                    }}
                  />
                );
              })}
              <text x={cx} y={H - 8} textAnchor="middle" fontSize="11" style={{ fill: "var(--color-faint)" }}>
                {l}
              </text>
            </g>
          );
        })}
      </svg>
      {hover && (
        <ChartTip x={hover.left} y={hover.top}>
          <div className="mb-0.5 opacity-70">{labels[hover.i]}</div>
          {series.map((s) => (
            <div key={s.name} className="flex justify-between gap-3">
              <span>{s.name}</span>
              <span className="tabular-nums">{f(s.values[hover.i])}</span>
            </div>
          ))}
        </ChartTip>
      )}
    </div>
  );
}
packages/ui/src/lib/scale.ts54 lines
/**
 * The value scale every chart reads from.
 *
 * A quantity is a position between low and high, and its mark takes the scale
 * colour at that position — so the same value is the same colour in a bar, a
 * dot, a ring and a meter anywhere on the page. `good="low"` flips it for
 * metrics where less is better. A comparison series stays in the flat context
 * colour, so the subject and its reference never compete.
 *
 * The colours are mixed in CSS rather than computed here, so a theme flip (or
 * your own redeclared stops) re-colours every mark without a re-render.
 */

export type Good = "high" | "low";

export const SCALE = { low: "var(--scale-low)", mid: "var(--scale-mid)", high: "var(--scale-high)" } as const;
export const CONTEXT = "var(--chart-context)";
export const GRID = "var(--chart-grid)";

const clamp01 = (n: number) => (n < 0 ? 0 : n > 1 ? 1 : n);

/** The colour at `t` ∈ [0, 1] on the low → mid → high scale, as a CSS value. */
export function scaleColor(t: number) {
  const u = clamp01(t);
  return u < 0.5
    ? `color-mix(in oklab, ${SCALE.low}, ${SCALE.mid} ${(u * 200).toFixed(1)}%)`
    : `color-mix(in oklab, ${SCALE.mid}, ${SCALE.high} ${((u - 0.5) * 200).toFixed(1)}%)`;
}

/** Where `v` sits between `min` and `max`, flipped when low is good. */
export function scaleT(v: number, min: number, max: number, good: Good = "high") {
  const t = max === min ? 0.5 : (v - min) / (max - min);
  return good === "low" ? 1 - t : t;
}

/** The scale's two ends, in the order a gradient should run for `good`. */
export const ends = (good: Good = "high") => (good === "low" ? [SCALE.high, SCALE.low] : [SCALE.low, SCALE.high]);

/** A round axis maximum above `v`: 1, 2, 2.5 or 5 times a power of ten. */
export function niceMax(v: number) {
  if (v <= 0) return 1;
  const p = Math.pow(10, Math.floor(Math.log10(v)));
  const n = v / p;
  return (n <= 1 ? 1 : n <= 2 ? 2 : n <= 2.5 ? 2.5 : n <= 5 ? 5 : 10) * p;
}

export type Format = (v: number) => string;

/** A number with at most `digits` decimals, in the reader's locale — unless you pass your own `format`. */
export function formatter(digits = 0, format?: Format): Format {
  if (format) return format;
  const nf = new Intl.NumberFormat(undefined, { maximumFractionDigits: digits });
  return (v) => nf.format(v);
}