LineChart
A line coloured by its own values, with a crosshair that snaps to the nearest period.
The idea
The emphasis series is stroked on the value scale over its own range, so where it runs high it is drawn in the scale's high end and where it dips it fades toward the low: the line's colour is its own reading. A wash under it is heaviest beneath the good end. Comparison series stay flat, and the newest value is labelled where the line ends, which is where the eye lands. It traces itself in the first time it is 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 { LineChart } from "mihirmodi-ui";
<LineChart
labels={["Jan", "Feb", "Mar", "Apr"]}
series={[
{ name: "ARR", values: [3.1, 3.3, 3.2, 3.6], emphasis: true },
{ name: "Plan", values: [3.0, 3.2, 3.4, 3.5] },
]}
digits={1}
unit="$"
callout="$3.6M, ahead of plan"
/>
/* Greyscale by default. For a coloured scale, redeclare the stops: */
:root {
--scale-low: #e5484d;
--scale-mid: #f2b833;
--scale-high: #2fa36b;
}Props
| Prop | Type | Notes |
|---|---|---|
| labelsstring[] | string[] | One per period, along the bottom. |
| series{ name, values, emphasis? }[] | { name, values, emphasis? }[] | The emphasis series gets the scale; the rest stay in the context colour. |
| digits / formatnumber / (v) => string | number / (v) => string= 0 | How figures are written. `format` overrides `digits`. |
| unitstring | string | Before each value in the tooltip and end label — a currency symbol. |
| good"high" | "low" | "high" | "low"= "high" | Flip the scale for a metric where less is better. |
| calloutReactNode | ReactNode | A glass note pinned to the last point, in place of the plain end label. |
| --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 { useId, useState, type ReactNode } from "react";
import { ChartTip, ScaleGradient } from "./ScaleGradient.js";
import { CONTEXT, GRID, formatter, niceMax, scaleColor, scaleT, type Format, type Good } from "../../lib/scale.js";
import { useDrawIn } from "../../lib/useDrawIn.js";
export type LineSeries = { name: string; values: number[]; emphasis?: boolean };
export type LineChartProps = {
labels: string[];
series: LineSeries[];
height?: number;
digits?: number;
format?: Format;
/** Before every value in the tooltip and end label — a currency symbol. */
unit?: string;
good?: Good;
/** A glass note pinned to the last point, in place of the plain end label. */
callout?: ReactNode;
};
/**
* A line chart with a crosshair that snaps to the nearest period.
*
* The emphasis series is stroked with the value scale over its own range, so
* where it runs high it is drawn in the scale's high end and where it dips it
* fades toward the low — the line's colour is its own reading. A wash under it
* is heaviest beneath the good end and vanishes beneath the bad. Comparison
* series stay flat, in the context colour. The newest value is labelled at the
* end of the line, where the eye lands, rather than in a legend.
*
* It draws itself in the first time it comes into view: the line traces left
* to right, then the wash fades up under it.
*/
export function LineChart({ labels, series, height = 220, digits = 0, format, unit = "", good = "high", callout }: LineChartProps) {
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;
const H = height;
const padL = 36, padR = 16, padT = 12, padB = 26;
const max = niceMax(Math.max(...series.flatMap((s) => s.values)) * 1.05);
const n = labels.length;
const x = (i: number) => padL + (i * (W - padL - padR)) / (n - 1);
const y = (v: number) => padT + (H - padT - padB) * (1 - v / max);
const ticks = [0, 0.25, 0.5, 0.75, 1].map((t) => t * max);
const path = (vals: number[]) => vals.map((v, i) => `${i ? "L" : "M"}${x(i).toFixed(1)},${y(v).toFixed(1)}`).join(" ");
const emph = series.find((s) => s.emphasis) ?? series[0];
const eMin = Math.min(...emph.values), eMax = Math.max(...emph.values);
const area = `${path(emph.values)} L${x(n - 1).toFixed(1)},${y(0)} L${x(0).toFixed(1)},${y(0)} Z`;
const draw = (delay = 0) =>
animate
? { strokeDasharray: 1, strokeDashoffset: drawn ? 0 : 1, transition: `stroke-dashoffset 900ms cubic-bezier(0.16,1,0.3,1) ${delay}ms` }
: {};
const onMove = (e: React.MouseEvent<HTMLDivElement>) => {
const rect = e.currentTarget.getBoundingClientRect();
const px = ((e.clientX - rect.left) / rect.width) * W;
let best = 0, bd = Infinity;
for (let i = 0; i < n; i++) {
const d = Math.abs(x(i) - px);
if (d < bd) { bd = d; best = i; }
}
setHover({ i: best, left: (x(best) / W) * rect.width, top: (y(emph.values[best]) / H) * rect.height });
};
return (
<div ref={ref} className="relative" onMouseMove={onMove} onMouseLeave={() => setHover(null)}>
<svg viewBox={`0 0 ${W} ${H}`} className="w-full" style={{ height }} role="img" aria-labelledby={`${id}t`}>
{/* One string, not text plus an expression: React 19 treats <title>
specially, and two text nodes inside it fail to hydrate. */}
<title id={`${id}t`}>{`${series.map((s) => s.name).join(" and ")} by period`}</title>
<defs>
<ScaleGradient id={`${id}s`} y0={y(eMin)} y1={y(eMax)} good={good} />
<ScaleGradient id={`${id}f`} y0={y(eMin)} y1={y(eMax)} good={good} opacity={good === "low" ? [0.2, 0.09, 0] : [0, 0.09, 0.2]} />
</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) => (
<text key={l} x={x(i)} y={H - 8} textAnchor="middle" fontSize="11" style={{ fill: "var(--color-faint)" }}>
{l}
</text>
))}
<path
d={area}
fill={`url(#${id}f)`}
style={animate ? { opacity: drawn ? 1 : 0, transition: "opacity 500ms ease-out 450ms" } : undefined}
/>
{series
.filter((s) => s !== emph)
.map((s) => (
<path key={s.name} d={path(s.values)} pathLength={1} fill="none" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ stroke: CONTEXT, ...draw(120) }} />
))}
<path d={path(emph.values)} pathLength={1} fill="none" stroke={`url(#${id}s)`} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={draw()} />
{hover && (
<g>
<line x1={x(hover.i)} x2={x(hover.i)} y1={padT} y2={H - padB} style={{ stroke: GRID }} strokeWidth="1" />
{series.map((s) => (
<circle
key={s.name}
cx={x(hover.i)}
cy={y(s.values[hover.i])}
r="4"
strokeWidth="2"
style={{ fill: s === emph ? scaleColor(scaleT(s.values[hover.i], eMin, eMax, good)) : CONTEXT, stroke: "var(--color-background-100)" }}
/>
))}
</g>
)}
{!callout && (
<text
x={x(n - 1) - 6}
y={y(emph.values[n - 1]) - 10}
textAnchor="end"
fontSize="11"
fontWeight="500"
style={{ fill: "var(--color-ink)", ...(animate ? { opacity: drawn ? 1 : 0, transition: "opacity 300ms ease-out 700ms" } : {}) }}
>
{unit}
{f(emph.values[n - 1])}
</text>
)}
</svg>
{callout && (
<div
aria-hidden
className="glass-nav dock-shadow pointer-events-none absolute flex items-center gap-1.5 whitespace-nowrap rounded-full border-[0.5px] border-gray-alpha-400 px-2.5 py-1.5 text-[11px] text-gray-1000 transition-opacity duration-300"
style={{
left: `${(x(n - 1) / W) * 100}%`,
top: `${(y(emph.values[n - 1]) / H) * 100}%`,
transform: "translate(-100%, calc(-100% - 12px))",
opacity: drawn ? 1 : 0,
transitionDelay: "700ms",
}}
>
<span className="inline-block h-1.5 w-1.5 rounded-full" style={{ background: scaleColor(scaleT(emph.values[n - 1], eMin, eMax, good)) }} />
{callout}
</div>
)}
{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">
{unit}
{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);
}
"use client";
import { useRef } from "react";
import { useInView, useReducedMotion } from "motion/react";
/**
* True once the element has come into view — the cue for a chart to draw
* itself. Once only: a chart that re-animated every time you scrolled past it
* would be performing, not reporting. Under reduced motion it is true from the
* start, so nothing grows at all.
*/
export function useDrawIn<T extends Element>() {
const ref = useRef<T>(null);
const seen = useInView(ref, { once: true, margin: "0px 0px -10% 0px" });
const reduce = useReducedMotion();
return { ref, drawn: !!reduce || seen, animate: !reduce };
}