Chart
PortedA chart on Apache ECharts, drawn in SVG. Series colors, labels, grid lines and the tooltip come from Monet's tokens; motion stops when the user asks for less. The drawing is an image named by aria-label, and children give screen readers its data. The ECharts instance is on ref.
import { TimeseriesChart, type TimeseriesData } from "@etched/monet/chart";
// A steady walk from a seed, so the examples look the same on every load.
const walk = (seed: number, length: number, start: number) => {
let state = seed;
let value = start;
return Array.from({ length }, () => {
state = (state * 16807) % 2147483647;
value = Math.max(0, value + (state / 2147483647 - 0.5) * start * 0.25);
return Math.round(value);
});
};
const hour = 3_600_000;
const day = 24 * hour;
const start = new Date(2026, 9, 19).getTime();
const over = (step: number, values: number[]): [number, number][] => values.map((value, i) => [start + i * step, value]);
const candidates: TimeseriesData[] = [{ name: "Active candidates", data: over(day, walk(3, 60, 900)) }];
export function Preview() {
return <TimeseriesChart aria-label="Active candidates, last 60 days" type="area" data={candidates} height={160} />;
}Installation
import { Chart, ChartLegend, TimeseriesChart } from "@etched/monet/chart";Examples
Time series
Lines over time. Hover for every series at that time; screen readers get a table of each series' latest, lowest and highest value.
- Tracer
- Orbit
- Foundry
import { TimeseriesChart, type TimeseriesData } from "@etched/monet/chart";
// A steady walk from a seed, so the examples look the same on every load.
const walk = (seed: number, length: number, start: number) => {
let state = seed;
let value = start;
return Array.from({ length }, () => {
state = (state * 16807) % 2147483647;
value = Math.max(0, value + (state / 2147483647 - 0.5) * start * 0.25);
return Math.round(value);
});
};
const hour = 3_600_000;
const start = new Date(2026, 9, 19).getTime();
const over = (step: number, values: number[]): [number, number][] => values.map((value, i) => [start + i * step, value]);
const traces: TimeseriesData[] = [
{ name: "Tracer", data: over(hour, walk(7, 48, 420)) },
{ name: "Orbit", data: over(hour, walk(19, 48, 260)) },
{ name: "Foundry", data: over(hour, walk(31, 48, 140)) },
];
export function TimeSeries() {
return <TimeseriesChart aria-label="Requests per hour by app" data={traces} yAxisMinInterval={1} tooltipValueFormat={(value) => `${value} req/h`} />;
}Area
One series needs no legend: the chart's name says what it shows.
import { TimeseriesChart, type TimeseriesData } from "@etched/monet/chart";
// A steady walk from a seed, so the examples look the same on every load.
const walk = (seed: number, length: number, start: number) => {
let state = seed;
let value = start;
return Array.from({ length }, () => {
state = (state * 16807) % 2147483647;
value = Math.max(0, value + (state / 2147483647 - 0.5) * start * 0.25);
return Math.round(value);
});
};
const hour = 3_600_000;
const day = 24 * hour;
const start = new Date(2026, 9, 19).getTime();
const over = (step: number, values: number[]): [number, number][] => values.map((value, i) => [start + i * step, value]);
const candidates: TimeseriesData[] = [{ name: "Active candidates", data: over(day, walk(3, 60, 900)) }];
export function Area() {
return <TimeseriesChart aria-label="Active candidates per day" type="area" data={candidates} height={240} />;
}Stacked bars
type="bar" stacks the series, with a surface hairline between the parts (Orbit's referral chart).
- Full-time
- Internship
- Not specified
import { TimeseriesChart, type TimeseriesData } from "@etched/monet/chart";
// A steady walk from a seed, so the examples look the same on every load.
const walk = (seed: number, length: number, start: number) => {
let state = seed;
let value = start;
return Array.from({ length }, () => {
state = (state * 16807) % 2147483647;
value = Math.max(0, value + (state / 2147483647 - 0.5) * start * 0.25);
return Math.round(value);
});
};
const hour = 3_600_000;
const day = 24 * hour;
const start = new Date(2026, 9, 19).getTime();
const over = (step: number, values: number[]): [number, number][] => values.map((value, i) => [start + i * step, value]);
// Orbit's daily referrals by type; "Not specified" takes the token for other values.
const referrals: TimeseriesData[] = [
{ name: "Full-time", data: over(day, walk(5, 14, 12)) },
{ name: "Internship", data: over(day, walk(11, 14, 6)) },
{ name: "Not specified", data: over(day, walk(23, 14, 3)), color: "var(--et-chart-other)" },
];
export function StackedBars() {
return <TimeseriesChart aria-label="Referrals per day by type" type="bar" data={referrals} height={240} yAxisMinInterval={1} />;
}Bars by category
Chart takes ECharts options for any other chart. Clicks come through onEvents.
Click a bar.
import { useMemo, useState } from "react";
import { Chart, type ChartEvents, type ChartOption } from "@etched/monet/chart";
const stages = ["Sourced", "Screen", "Onsite", "Offer", "Hired"];
const counts = [1240, 410, 96, 31, 22];
export function BarsByCategory() {
const [stage, setStage] = useState("");
const options = useMemo<ChartOption>(
() => ({
tooltip: { trigger: "axis", axisPointer: { type: "shadow" } },
xAxis: { type: "value" },
yAxis: { type: "category", data: stages, inverse: true },
series: [{ type: "bar", name: "Candidates", data: counts }],
}),
[],
);
const events = useMemo<ChartEvents>(() => ({ click: (params) => setStage(params.name) }), []);
return (
<div className="grid">
<Chart aria-label="Candidates per stage" options={options} onEvents={events} height={220}>
<table>
<caption>Candidates per stage</caption>
<tbody>
{stages.map((name, i) => (
<tr key={name}>
<th scope="row">{name}</th>
<td>{counts[i]}</td>
</tr>
))}
</tbody>
</table>
</Chart>
<p>{stage ? `Clicked: ${stage}` : "Click a bar."}</p>
</div>
);
}Accessibility
- The drawing is an
img, named byaria-labeloraria-labelledby. It takes no focus, and its tooltip shows only for the pointer. - Screen readers get
childrenin a hidden box after the drawing.TimeseriesChartputs a table there of each series' latest, lowest and highest values. - The legend is a list of series names; its color squares are not read. With reduced motion, the chart does not animate.
- Name every chart with
aria-label. Give a plainChartchildren, such as a summary or a table of its data. - Show a
legendfor two or more series, so color is never the only key.TimeseriesChartdoes this by default.
API reference
ChartLegend
| Prop | Type | Default | Description |
|---|---|---|---|
| items * | ChartLegendItem[] | – |
Chart
| Prop | Type | Default | Description |
|---|---|---|---|
| options * | ChartOption | – | ECharts options. Line and bar series are ready; register others with use from "echarts/core". |
| optionUpdateBehavior | SetOptionOpts | { lazyUpdate: true, replaceMerge: ["series"] }, so a series left out goes away | The second argument to setOption. |
| height | number | 320 | Pixels. |
| onEvents | ChartEvents | – | ECharts events to listen to. Keep the object stable (useMemo), or the handlers bind again on every render. |
| legend | ChartLegendItem[] | – | Series names above the chart. |
| children | ReactNode | – | What screen readers get in place of the drawing: a summary, or a table of the data. Name the chart with aria-label. |
TimeseriesChart
| Prop | Type | Default | Description |
|---|---|---|---|
| optionUpdateBehavior | SetOptionOpts | { lazyUpdate: true, replaceMerge: ["series"] }, so a series left out goes away | The second argument to setOption. |
| height | number | 320 | Pixels. |
| onEvents | ChartEvents | – | ECharts events to listen to. Keep the object stable (useMemo), or the handlers bind again on every render. |
| children | ReactNode | – | What screen readers get in place of the drawing: a summary, or a table of the data. Name the chart with aria-label. |
| data * | TimeseriesData[] | – | |
| type | "line" | "area" | "bar" | "line" | bar stacks the series (Kumo). |
| locale | string | the browser's language | A BCP 47 language tag for the default formats. |
| xAxisName | string | – | |
| yAxisName | string | – | |
| xAxisTickFormat | (time: number) => string | ECharts' own, by the scale of the range | The time axis labels. |
| yAxisTickFormat | (value: number) => string | compact numbers: 1.2K | The value axis labels. |
| yAxisMinInterval | number | – | The smallest step on the value axis: 1 for counts, so no tick is a fraction. |
| tooltipValueFormat | (value: number) => string | the number in the locale | Values in the tooltip and the data table. |
| tooltipTimestampFormat | (time: number) => string | medium date and short time in the locale | Times in the tooltip and the data table. |
| legend | boolean | true with two or more series | Series names above the chart. |