Monet
@etched/monet 0.0.1

Chart

Ported

A 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 by aria-label or aria-labelledby. It takes no focus, and its tooltip shows only for the pointer.
  • Screen readers get children in a hidden box after the drawing. TimeseriesChart puts 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 plain Chart children, such as a summary or a table of its data.
  • Show a legend for two or more series, so color is never the only key. TimeseriesChart does this by default.

API reference

ChartLegend

PropTypeDefaultDescription
items *ChartLegendItem[]–

Chart

PropTypeDefaultDescription
options *ChartOption–ECharts options. Line and bar series are ready; register others with use from "echarts/core".
optionUpdateBehaviorSetOptionOpts{ lazyUpdate: true, replaceMerge: ["series"] }, so a series left out goes awayThe second argument to setOption.
heightnumber320Pixels.
onEventsChartEvents–ECharts events to listen to. Keep the object stable (useMemo), or the handlers bind again on every render.
legendChartLegendItem[]–Series names above the chart.
childrenReactNode–What screen readers get in place of the drawing: a summary, or a table of the data. Name the chart with aria-label.

TimeseriesChart

PropTypeDefaultDescription
optionUpdateBehaviorSetOptionOpts{ lazyUpdate: true, replaceMerge: ["series"] }, so a series left out goes awayThe second argument to setOption.
heightnumber320Pixels.
onEventsChartEvents–ECharts events to listen to. Keep the object stable (useMemo), or the handlers bind again on every render.
childrenReactNode–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).
localestringthe browser's languageA BCP 47 language tag for the default formats.
xAxisNamestring–
yAxisNamestring–
xAxisTickFormat(time: number) => stringECharts' own, by the scale of the rangeThe time axis labels.
yAxisTickFormat(value: number) => stringcompact numbers: 1.2KThe value axis labels.
yAxisMinIntervalnumber–The smallest step on the value axis: 1 for counts, so no tick is a fraction.
tooltipValueFormat(value: number) => stringthe number in the localeValues in the tooltip and the data table.
tooltipTimestampFormat(time: number) => stringmedium date and short time in the localeTimes in the tooltip and the data table.
legendbooleantrue with two or more seriesSeries names above the chart.