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.jsonUsage
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.
| Prop | Type | Description |
|---|---|---|
| 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".
| Prop | Type | Description |
|---|---|---|
| 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.
| Prop | Type | Description |
|---|---|---|
| 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.