Sparkline
A trend small enough to sit beside a number, with the latest point as a dot.
Revenue
Churn
The idea
Stroked on the value scale over its own range, so the line darkens where it runs high — and flips with good=low, so falling churn reads the same way as rising revenue. The dot is the only reading on it; everything else is shape. It traces in once and the dot lands when the line reaches it.
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 { Sparkline } from "mihirmodi-ui";
// Beside a figure that already says what it is, it needs no label.
<span className="flex items-end gap-3">
<strong>$4.2M</strong>
<Sparkline values={arr} />
</span>
// For a metric where less is better, flip the scale.
<Sparkline values={churn} good="low" label="Churn, last 8 months" />Props
| Prop | Type | Notes |
|---|---|---|
| valuesnumber[] | number[] | Oldest first. The last is the dot. |
| width / heightnumber | number= 96 / 28 | In pixels. |
| good"high" | "low" | "high" | "low"= "high" | Flip the scale. |
| labelstring | string | Give it one when it stands alone; leave it out beside a figure that already says it. |
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 } from "react";
import { ScaleGradient } from "./ScaleGradient.js";
import { scaleColor, scaleT, type Good } from "../../lib/scale.js";
import { useDrawIn } from "../../lib/useDrawIn.js";
export type SparklineProps = { values: number[]; width?: number; height?: number; good?: Good; label?: string };
/**
* A trend small enough to sit beside a number. Stroked on the value scale over
* its own range, with the current period marked as a dot in its own colour —
* the only point on it that is a reading rather than a shape. It traces in once,
* and the dot lands when the line reaches it.
*/
export function Sparkline({ values, width = 96, height = 28, good = "high", label }: SparklineProps) {
const id = useId().replace(/:/g, "");
const { ref, drawn, animate } = useDrawIn<SVGSVGElement>();
const min = Math.min(...values);
const max = Math.max(...values);
const span = max - min || 1;
const step = width / (values.length - 1);
const pts = values.map((v, i) => [i * step, height - 3 - ((v - min) / span) * (height - 6)] as const);
const d = pts.map((p, i) => `${i ? "L" : "M"}${p[0].toFixed(1)},${p[1].toFixed(1)}`).join(" ");
const last = pts[pts.length - 1];
return (
<svg
ref={ref}
width={width}
height={height}
viewBox={`-3 0 ${width + 6} ${height}`}
role={label ? "img" : undefined}
aria-label={label}
aria-hidden={label ? undefined : true}
className="overflow-visible"
>
<defs>
<ScaleGradient id={id} y0={height - 3} y1={3} good={good} />
</defs>
<path
d={d}
pathLength={1}
fill="none"
stroke={`url(#${id})`}
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
style={animate ? { strokeDasharray: 1, strokeDashoffset: drawn ? 0 : 1, transition: "stroke-dashoffset 800ms cubic-bezier(0.16,1,0.3,1)" } : undefined}
/>
<circle
cx={last[0]}
cy={last[1]}
r="3"
strokeWidth="2"
style={{
fill: scaleColor(scaleT(values[values.length - 1], min, max, good)),
stroke: "var(--color-background-100)",
...(animate ? { opacity: drawn ? 1 : 0, transition: "opacity 200ms ease-out 650ms" } : {}),
}}
/>
</svg>
);
}
/**
* 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);
}