← All components

LineChart

A line coloured by its own values, with a crosshair that snaps to the nearest period.

ARR and Plan by period01.32.53.85JanFebMarAprMayJunJulAug

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

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

LineChart — usage19 lines
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

PropNotes
labelsstring[]One per period, along the bottom.
series{ name, values, emphasis? }[]The emphasis series gets the scale; the rest stay in the context colour.
digits / formatnumber / (v) => stringHow figures are written. `format` overrides `digits`.
unitstringBefore each value in the tooltip and end label — a currency symbol.
good"high" | "low"Flip the scale for a metric where less is better.
calloutReactNodeA glass note pinned to the last point, in place of the plain end label.
--scale-low / --scale-mid / --scale-highCSS variablesThe 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.

packages/ui/src/components/data/LineChart.tsx164 lines
"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>
  );
}
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);
}
packages/ui/src/lib/useDrawIn.ts17 lines
"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 };
}