Spark chartFree

A trend for a table cell or a KPI row: line, area or bar, with gaps kept and the latest value marked. Plain SVG: no chart engine.

npx shadcn@latest add https://beautifulcharts.dev/r/spark-chart.json

Usage

A complete client module. Install the item first, then paste it into your app.

"use client";

import { SparkChart } from "@/components/beautiful-charts/themes/instrument/spark-chart";

const accounts = [
  { name: "Account 01", trend: [{ week: "W36", seats: 12 }, { week: "W37", seats: 14 }, { week: "W38", seats: null }, { week: "W39", seats: 17 }, { week: "W40", seats: 19 }] },
  { name: "Account 02", trend: [{ week: "W36", seats: 30 }, { week: "W37", seats: 28 }, { week: "W38", seats: 26 }, { week: "W39", seats: 27 }, { week: "W40", seats: 22 }] },
];

// One spark per row. The missing week stays a gap, and each spark is read as one sentence.
export function SeatsTable() {
  return (
    <table>
      <tbody>
        {accounts.map((account) => (
          <tr key={account.name}>
            <th scope="row">{account.name}</th>
            <td style={{ width: 120 }}>
              <SparkChart data={account.trend} xKey="week" valueKey="seats" label={`${account.name} seats`} type="area" />
            </td>
          </tr>
        ))}
      </tbody>
    </table>
  );
}

Props

  • data*

    TDatum[]

    Your rows, oldest first.

  • valueKey*

    keyof TDatum & string

    The number to draw, on each row.

  • xKey

    keyof TDatum & string

    A label for each row, e.g. a month. Used in the summary; without it, points are counted.

  • label

    string

    What the number is, e.g. "Sign-ups". Names the spark for screen readers.

  • polarity

    "up" | "down"

    "up" when a rise is good (default), "down" for costs, churn, latency.

  • valueFormatter

    (value: number) => string

    Formats the numbers in the summary.

Sourcecomponents/beautiful-charts/themes/instrument/spark-chart.tsx

Show the source (103 lines)
"use client";

import { useEffect, useRef, useState, type CSSProperties } from "react";
import { useSparkModel, type SparkProps } from "../../core/spark";
import { linePath, type Point } from "../../core/plot";
import { InstrumentStyles } from "./styles";

export type SparkChartProps<TDatum extends Record<string, unknown>> = SparkProps<TDatum> & {
  /** "line" (default), "area" or "bar". */
  type?: "line" | "area" | "bar" | undefined;
  /** Height in px (default 32). The width fills its container. */
  height?: number | undefined;
  /** The mark colour; defaults to the theme's first palette colour. Any CSS colour or var(). */
  color?: string | undefined;
  /** Classes on the spark, e.g. a fixed width such as "w-28". */
  className?: string | undefined;
};

/** Sample data used when you install the spark; replace it with your own rows. */
export const sparkChartSample = [
  { month: "Apr", signups: 182 }, { month: "May", signups: 204 }, { month: "Jun", signups: 196 }, { month: "Jul", signups: 218 },
  { month: "Aug", signups: 231 }, { month: "Sep", signups: 248 }, { month: "Oct", signups: 239 }, { month: "Nov", signups: 266 },
];

/**
 * Spark chart, Instrument theme. Plain SVG: no chart engine.
 * A trend for a table cell or a KPI row: line, area or bar, no axes and no tab stop. Gaps stay gaps; the latest
 * value is marked; screen readers get one sentence with the latest value, the change and the range.
 */
export function SparkChart<TDatum extends Record<string, unknown>>(props: SparkChartProps<TDatum>) {
  const m = useSparkModel(props);
  const type = props.type ?? "line";
  const height = props.height ?? 32;
  const ref = useRef<HTMLSpanElement>(null);
  const [width, setWidth] = useState(0);
  const [entered, setEntered] = useState(false);
  useEffect(() => {
    const element = ref.current;
    if (!element) return;
    const observer = new ResizeObserver(([entry]) => setWidth(entry!.contentRect.width));
    observer.observe(element);
    setEntered(true);
    return () => observer.disconnect();
  }, []);

  const pad = type === "bar" ? 0 : 3;
  const count = m.values.length;
  const knownValues = m.known.map((p) => p.v);
  // Bars and areas stand on zero; a line uses the data's own range so small changes stay visible.
  const floor = type === "line" ? Math.min(...knownValues) : Math.min(0, ...knownValues);
  const ceiling = type === "line" ? Math.max(...knownValues) : Math.max(0, ...knownValues);
  const span = ceiling - floor || 1;
  const y = (v: number) => pad + (1 - (v - floor) / span) * (height - 2 * pad);
  const x = (i: number) => (count <= 1 ? width / 2 : pad + (i * (width - 2 * pad)) / (count - 1));
  const points = m.values.map((v, i) => (v === null ? null : ([x(i), y(v)] as Point)));
  const last = m.last ? points[m.last.i] : null;

  let marks = null;
  if (width > 0 && m.known.length) {
    if (type === "bar") {
      // Bars stay inside their own slot, so a missing value's slot stays visibly empty however dense the data.
      const slot = width / count, bar = Math.min(slot, Math.max(1, slot * 0.7)), zero = y(0);
      // The latest bar is marked like the latest point of a line: a dot just past its end.
      const lastV = m.last?.v ?? 0, lastI = m.last?.i ?? -1;
      const lastDot = lastI >= 0 ? <circle key="last" className={"bc-late bc-spark-dot"} cx={lastI * slot + slot / 2} cy={lastV >= 0 ? y(lastV) - 4 : y(lastV) + 4} r={2.5} /> : null;
      marks = [...m.values.map((v, i) => v === null ? null : (
        <rect key={i} className={"bc-late bc-spark-bar"} data-last={m.last?.i === i ? "" : undefined}
          x={i * slot + (slot - bar) / 2} y={Math.min(zero, y(v))} width={bar} height={Math.max(1, Math.abs(zero - y(v)))} rx={Math.min(2, bar / 2, Math.abs(zero - y(v)) / 2)} />
      )), lastDot];
    } else {
      const line = linePath(points, "monotone");
      // The area closes down to the floor under each run of known points, so a gap stays open.
      const runs: Point[][] = [];
      points.forEach((p) => { if (p) { if (!runs.length || runs[runs.length - 1]!.length === 0) runs.push([]); runs[runs.length - 1]!.push(p); } else if (runs.length && runs[runs.length - 1]!.length) runs.push([]); });
      const area = type === "area" ? runs.filter((run) => run.length).map((run) => `${linePath(run, "monotone")} L${run[run.length - 1]![0]},${y(Math.max(floor, 0))} L${run[0]![0]},${y(Math.max(floor, 0))} Z`).join(" ") : "";
      // A known value with gaps on both sides has no line to sit on; it gets its own small mark.
      const alone = points.flatMap((p, i) => (p && !points[i - 1] && !points[i + 1] && i !== m.last?.i ? [p] : []));
      marks = (
        <>
          {area ? <path className={"bc-late bc-spark-area"} d={area} /> : null}
          {/* pathLength 1: the draw-in dash always matches the whole line, so no part of it stays hidden. */}
          <path className={"bc-draw bc-spark-line"} d={line} pathLength={1} style={{ ["--len" as string]: 1 }} />
          {alone.map((p, i) => <circle key={i} className={"bc-late bc-spark-point"} cx={p[0]} cy={p[1]} r={1.75} />)}
          {last ? <circle className={"bc-late bc-spark-dot"} cx={last[0]} cy={last[1]} r={2.5} /> : null}
        </>
      );
    }
  }

  return (
    <span ref={ref} data-slot="spark" data-bc-theme={"Instrument".toLowerCase()} data-type={type} data-tone={m.change?.tone} data-entered={entered && m.k > 0 ? "" : undefined}
      role="img" aria-label={m.summary} className={props.className}
      style={{ height, ["--bc-k" as string]: m.k, ...(props.color ? { ["--spark-color" as string]: props.color } : {}) } as CSSProperties}>
      <InstrumentStyles />
      {width > 0 ? (
        <svg width={width} height={height} aria-hidden="true">
          {m.known.length ? marks : <line className={"bc-spark-empty"} x1={0} x2={width} y1={height / 2} y2={height / 2} />}
        </svg>
      ) : null}
    </span>
  );
}

Details

The other 5 spark chart props
  • motion

    MotionPreference

    "full" (default), "subtle" or "off". Reduced-motion settings always win.

  • type

    "line" | "area" | "bar"

    "line" (default), "area" or "bar".

  • height

    number

    Height in px (default 32). The width fills its container.

  • color

    string

    The mark colour; defaults to the theme's first palette colour. Any CSS colour or var().

  • className

    string

    Classes on the spark, e.g. a fixed width such as "w-28".

Props every chart shares (2): locale, timeZone
  • locale

    string

    BCP 47 locale. Defaults to "en-US" so server and client render the same text.

  • timeZone

    string

    IANA time zone for dates. Defaults to "UTC" so server and client agree.

Pointer
Not interactive.
Keyboard
Not focusable: there's nothing to move between, so a table of sparks adds no tab stops.
Screen readers
An image (role="img") named by one sentence: the latest value, the change from the first known value, the range, and how many values are missing.
Motion
The line draws left to right, then the area, bars and latest point fade in. With reduced motion it appears settled at once.