BarChart
Horizontal bars that each reveal one shared gradient, so a bar's tip is its value.
- Annual plans91%
- Quarterly83%
- Monthly74%
- Trial converts58%
The idea
Every bar shows the same gradient across the track, cut off where the bar ends, so the colour at its tip is its value and the longest bar reaches the scale's far end. With negative values they grow either way from a centre baseline. Pointing at a row dims the others, which is the one thing a bar chart needs on hover. The bars grow out from the baseline, one after another, the first time they are seen.
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 { BarChart } from "mihirmodi-ui";
<BarChart
rows={[
{ name: "Annual plans", value: 91, emphasis: true },
{ name: "Monthly", value: 74 },
]}
format={(v) => `${v}%`}
/>
// Negative values grow left from a centre baseline.
<BarChart rows={[{ name: "Q1", value: 4.2 }, { name: "Q2", value: -1.8 }]} />Props
| Prop | Type | Notes |
|---|---|---|
| rows{ name, value, emphasis? }[] | { name, value, emphasis? }[] | Negative values grow left from a centre baseline. |
| digits / formatnumber / (v) => string | number / (v) => string= 1 | How the figures at the ends are written. |
| unitstring | string | Before each figure. |
| good"high" | "low" | "high" | "low"= "high" | Flip the scale for a metric where less is better. |
| --scale-low / --scale-mid / --scale-highCSS variables | CSS variables | The value scale. Light grey to ink by default; redeclare the three stops for colour. |
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 { useState } from "react";
import { SCALE, ends, formatter, niceMax, type Format, type Good } from "../../lib/scale.js";
import { useDrawIn } from "../../lib/useDrawIn.js";
export type BarRow = { name: string; value: number; emphasis?: boolean };
export type BarChartProps = {
rows: BarRow[];
digits?: number;
format?: Format;
unit?: string;
good?: Good;
};
/**
* Horizontal bars that reveal one shared gradient.
*
* Every bar shows the same gradient across the track, cut off where the bar
* ends — so the colour at a bar's tip is its value, and the longest bar reaches
* the scale's far end. With negative values the bars grow either way from a
* centre baseline. Pointing at a row dims the others, which is the one thing a
* bar chart needs on hover: which one you are reading.
*
* The bars grow out from the baseline the first time the chart is seen.
*/
export function BarChart({ rows, digits = 1, format, unit = "", good = "high" }: BarChartProps) {
const f = formatter(digits, format);
const { ref, drawn, animate } = useDrawIn<HTMLUListElement>();
const [hover, setHover] = useState<number | null>(null);
const hasNeg = rows.some((r) => r.value < 0);
const top = Math.max(...rows.map((r) => Math.abs(r.value)));
const maxAbs = niceMax(top * 1.05);
// The gradient spans the longest bar, not the axis.
const span = (v: number) => `${v > 0 ? (top / v) * 100 : 100}% 100%`;
const [lo, hi] = ends(good);
return (
<ul ref={ref} className="flex flex-col gap-2.5">
{rows.map((r, i) => {
const pct = drawn ? (Math.abs(r.value) / maxAbs) * 100 : 0;
const dim = hover === null || hover === i ? 1 : 0.55;
const grow = {
opacity: dim,
transition: animate ? `width 700ms cubic-bezier(0.16,1,0.3,1) ${i * 60}ms, left 700ms cubic-bezier(0.16,1,0.3,1) ${i * 60}ms, opacity 200ms` : "opacity 200ms",
};
return (
<li
key={r.name}
className="grid grid-cols-[minmax(0,42%)_1fr_auto] items-center gap-3"
onMouseEnter={() => setHover(i)}
onMouseLeave={() => setHover(null)}
>
<span className={`truncate text-[13px] ${r.emphasis ? "font-medium text-gray-1000" : "text-gray-900"}`}>{r.name}</span>
<div className="relative h-5">
{hasNeg ? (
<>
<span aria-hidden className="absolute inset-y-0 left-1/2 w-px bg-gray-400" />
<span
className="absolute top-1/2 h-4 -translate-y-1/2"
style={{
...grow,
width: `${pct / 2}%`,
left: r.value < 0 ? `calc(50% - ${pct / 2}%)` : "50%",
borderRadius: r.value < 0 ? "2px 0 0 2px" : "0 2px 2px 0",
backgroundImage: r.value < 0 ? `linear-gradient(to left, ${SCALE.mid}, ${SCALE.low})` : `linear-gradient(to right, ${SCALE.mid}, ${SCALE.high})`,
backgroundSize: span(Math.abs(r.value)),
backgroundPosition: r.value < 0 ? "right" : "left",
backgroundRepeat: "no-repeat",
}}
/>
</>
) : (
<span
className="absolute left-0 top-1/2 h-4 -translate-y-1/2 rounded-r-[2px]"
style={{
...grow,
width: `${pct}%`,
backgroundImage: `linear-gradient(to right, ${lo}, ${SCALE.mid}, ${hi})`,
backgroundPosition: "left",
backgroundRepeat: "no-repeat",
backgroundSize: span(Math.abs(r.value)),
}}
/>
)}
</div>
<span className={`min-w-12 text-right text-[13px] tabular-nums ${r.emphasis ? "font-medium text-gray-1000" : "text-gray-900"}`}>
{r.value < 0 ? "−" : ""}
{unit}
{f(Math.abs(r.value))}
</span>
</li>
);
})}
</ul>
);
}
/**
* 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);
}