← All components

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

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

Sparkline — usage10 lines
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

PropNotes
valuesnumber[]Oldest first. The last is the dot.
width / heightnumberIn pixels.
good"high" | "low"Flip the scale.
labelstringGive 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.

packages/ui/src/components/data/Sparkline.tsx63 lines
"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>
  );
}
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);
}