ColumnChart
Grouped columns for a few short series, rising from the axis a period at a time.
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
npm i mihirmodi-ui motionThe 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
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
| Prop | Type | Notes |
|---|---|---|
| labelsstring[] | string[] | One per group. |
| series{ name, values, emphasis? }[] | { name, values, emphasis? }[] | Keep it to two or three; past that, a line reads better. |
| heightnumber | number= 180 | The chart scales to its width at this height. |
| digits / formatnumber / (v) => string | number / (v) => string= 0 | Axis and tooltip figures. |
| good"high" | "low" | "high" | "low"= "high" | 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.
"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>
);
}
/**
* 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);
}