Charts
Clawscale ships chart tokens, not a chart library. Use them with the chart library you already have, or draw small charts in SVG.
Usage#
In SVG and CSS, read the tokens as CSS variables. They follow the theme and the color scheme.
<path d={line} fill="none" stroke="var(--cs-chart-1)" strokeWidth={2} />Libraries that need color values import them from @clawscale/tokens.
import { chartPalettes } from "@clawscale/tokens";Categorical palette#
Eight slots, --cs-chart-1 to --cs-chart-8, tell series apart. Each theme and color scheme has its own values, tuned for its surface.
import { Classes, ClawscaleClasses } from "@clawscale/react";
const slots = [1, 2, 3, 4, 5, 6, 7, 8];
export default function ChartsPalette() {
return (
<div style={{ display: "grid", gap: 8, gridTemplateColumns: "repeat(4, minmax(0, 1fr))" }}>
{slots.map((slot) => (
<div key={slot} style={{ display: "grid", gap: 6 }}>
<div style={{ background: `var(--cs-chart-${slot})`, borderRadius: "var(--cs-radius-sm)", height: 32 }} />
<span className={`${ClawscaleClasses.MONOSPACE} ${Classes.TEXT_MUTED}`}>--cs-chart-{slot}</span>
</div>
))}
</div>
);
}- Assign slots in order: the first series gets slot 1, the second slot 2. The order keeps neighboring colors distinct for color-blind readers.
- Never skip, reorder or cycle slots. A ninth series folds into Other, or the chart splits into small multiples.
- Color follows the series, not its rank. When a filter hides a series, the others keep their colors.
- One series uses slot 1 and needs no legend. The title names it.
- Two or more series need a legend. Up to four also get direct labels.
- In the default theme's light color scheme, slots 3, 4 and 5 fall below 3:1 contrast on the surface. Label those series directly or offer a table.
Status colors#
--cs-chart-good, --cs-chart-warning, --cs-chart-serious and --cs-chart-critical show state, never series identity. They are the same in every theme and color scheme. Warning and serious are low contrast on light surfaces by design, so always show a status color with an icon and a label.
import { Icon } from "@clawscale/react";
const states = [
{ token: "--cs-chart-good", icon: "tick-circle", label: "42 healthy" },
{ token: "--cs-chart-warning", icon: "warning-sign", label: "3 degraded" },
{ token: "--cs-chart-serious", icon: "error", label: "1 failing" },
{ token: "--cs-chart-critical", icon: "cross-circle", label: "1 down" },
] as const;
export default function ChartsStatus() {
return (
<div style={{ display: "flex", flexWrap: "wrap", gap: 20 }}>
{states.map((state) => (
<span key={state.token} style={{ alignItems: "center", display: "inline-flex", gap: 6 }}>
<Icon icon={state.icon} color={`var(${state.token})`} />
{state.label}
</span>
))}
</div>
);
}Grid and axis#
--cs-chart-grid draws hairline gridlines and --cs-chart-axis draws the baseline. Both stay quiet so the data leads. Axis labels and values use text tokens such as --cs-color-text-muted, never a series color.
Sequential ramp#
--cs-chart-seq-100 to --cs-chart-seq-700 step one hue from light to dark: blue in the default theme. Use it for magnitude, such as heatmap cells. In the light color scheme, map values near zero to 100. In the dark color scheme, reverse the ramp so low values stay close to the surface.
import { Classes, ClawscaleClasses } from "@clawscale/react";
const steps = [100, 200, 300, 400, 500, 600, 700];
export default function ChartsSequential() {
return (
<div style={{ display: "grid", gap: 2, gridTemplateColumns: "repeat(7, minmax(0, 1fr))", maxWidth: 480 }}>
{steps.map((step) => (
<div key={step} style={{ display: "grid", gap: 6 }}>
<div style={{ background: `var(--cs-chart-seq-${step})`, height: 32 }} />
<span className={`${ClawscaleClasses.NUMERIC} ${Classes.TEXT_MUTED}`}>{step}</span>
</div>
))}
</div>
);
}Rules for hand-built charts#
- One y axis. Two measures with different scales get two charts.
- Lines are 2px. Bars sit 2px apart, with 4px rounded corners at the data end and a square base.
- Gridlines are solid hairlines in
--cs-chart-grid. - Labels and values use text colors. The colored mark next to a label carries identity.
- Numbers use tabular figures: add
ClawscaleClasses.NUMERICto thesvg. - Every mark has a native
<title>, which the browser shows on hover. - Every chart has a visually hidden data table or an
aria-labelsummary for screen readers.
Examples#
Bar chart#
One series in slot 1. The title names it, so there is no legend. Only the peak has a value label: hover a bar for the others. The aria-label lists every value for screen readers.
import { Classes, ClawscaleClasses } from "@clawscale/react";
const runs = [
312, 298, 276, 264, 251, 270, 355, 512, 734, 918, 1046, 1122, 1180, 1204, 1236, 1190, 1102, 986, 842, 701, 588, 472,
401, 346,
];
const hour = (index: number) => `${String(index).padStart(2, "0")}:00`;
const width = 560;
const height = 200;
const left = 48;
const top = 8;
const baseline = 172;
const max = 1600;
const ticks = [0, 400, 800, 1200, 1600];
const band = (width - left) / runs.length;
const y = (value: number) => baseline - (value / max) * (baseline - top);
const count = new Intl.NumberFormat("en-US");
const peak = Math.max(...runs);
const peakX = left + (runs.indexOf(peak) + 0.5) * band;
/** A bar with a square base and 4px rounded corners at its data end. */
function bar(x: number, barTop: number, w: number) {
const r = Math.min(4, w / 2, baseline - barTop);
return `M${x} ${baseline}V${barTop + r}a${r} ${r} 0 0 1 ${r} ${-r}h${w - 2 * r}a${r} ${r} 0 0 1 ${r} ${r}V${baseline}Z`;
}
export default function ChartsBarChart() {
const summary = runs.map((value, index) => `${hour(index)} ${count.format(value)}`).join(", ");
return (
<figure style={{ fontSize: "var(--cs-font-size-sm)", margin: 0, maxWidth: width }}>
<figcaption style={{ marginBottom: 8 }}>
<strong>Pipeline runs per hour</strong> <span className={Classes.TEXT_MUTED}>Last 24 hours, UTC</span>
</figcaption>
<svg
viewBox={`0 0 ${width} ${height}`}
width="100%"
role="img"
aria-label={`Pipeline runs per hour: ${summary}`}
className={ClawscaleClasses.NUMERIC}
>
<g stroke="var(--cs-chart-grid)" shapeRendering="crispEdges">
{ticks.map((tick) => (
<path key={tick} d={`M${left} ${y(tick)}H${width}`} />
))}
</g>
<g fill="var(--cs-chart-1)">
{runs.map((value, index) => (
<path key={hour(index)} d={bar(left + index * band + 1, y(value), band - 2)}>
<title>{`${hour(index)}: ${count.format(value)} runs`}</title>
</path>
))}
</g>
<path d={`M${left} ${baseline}H${width}`} stroke="var(--cs-chart-axis)" shapeRendering="crispEdges" />
<g fill="var(--cs-color-text-muted)">
{ticks.map((tick) => (
<text key={tick} x={left - 8} y={y(tick)} dy="0.32em" textAnchor="end">
{count.format(tick)}
</text>
))}
{[0, 6, 12, 18].map((at) => (
<text key={at} x={left + (at + 0.5) * band} y={height - 8} textAnchor="middle">
{hour(at)}
</text>
))}
</g>
<text x={peakX} y={y(peak) - 6} textAnchor="middle" fill="var(--cs-color-text)">
{count.format(peak)}
</text>
</svg>
</figure>
);
}Line chart#
Three series in slots 1, 2 and 3, with a legend and a label at the end of each line. Hover a point for its value. A visually hidden table holds the data for screen readers. It comes from the _chart-table.tsx helper next to the example.
| Hour (UTC) | us-east-1 | eu-west-1 | ap-south-1 |
|---|---|---|---|
| 01:00 | 6,050 | 4,500 | 5,400 |
| 02:00 | 5,900 | 4,800 | 5,300 |
| 03:00 | 5,850 | 5,200 | 5,000 |
| 04:00 | 6,100 | 5,600 | 4,600 |
| 05:00 | 6,600 | 6,000 | 4,200 |
| 06:00 | 7,300 | 6,300 | 3,900 |
| 07:00 | 8,100 | 6,400 | 3,700 |
| 08:00 | 8,700 | 6,350 | 3,500 |
| 09:00 | 9,000 | 6,250 | 3,400 |
| 10:00 | 9,200 | 6,200 | 3,300 |
| 11:00 | 9,350 | 6,150 | 3,250 |
| 12:00 | 9,400 | 6,100 | 3,200 |
import { Classes, ClawscaleClasses } from "@clawscale/react";
import { ChartTable } from "./_chart-table";
/** Each region keeps its slot, so hiding one never repaints the others. */
const series = [
{ name: "us-east-1", slot: 1, values: [6050, 5900, 5850, 6100, 6600, 7300, 8100, 8700, 9000, 9200, 9350, 9400] },
{ name: "eu-west-1", slot: 2, values: [4500, 4800, 5200, 5600, 6000, 6300, 6400, 6350, 6250, 6200, 6150, 6100] },
{ name: "ap-south-1", slot: 3, values: [5400, 5300, 5000, 4600, 4200, 3900, 3700, 3500, 3400, 3300, 3250, 3200] },
].map((s) => ({ ...s, color: `var(--cs-chart-${s.slot})`, last: s.values.at(-1) ?? 0 }));
const hours = Array.from({ length: 12 }, (_, index) => `${String(index + 1).padStart(2, "0")}:00`);
const width = 560;
const height = 200;
const left = 40;
const right = 456;
const top = 8;
const baseline = 172;
const max = 10000;
const ticks = [0, 2500, 5000, 7500, 10000];
const x = (index: number) => left + (index * (right - left)) / (hours.length - 1);
const y = (value: number) => baseline - (value / max) * (baseline - top);
const line = (values: number[]) => values.map((value, index) => `${index ? "L" : "M"}${x(index)} ${y(value)}`).join("");
const count = new Intl.NumberFormat("en-US");
const compact = new Intl.NumberFormat("en-US", { notation: "compact" });
export default function ChartsLineChart() {
return (
<figure style={{ fontSize: "var(--cs-font-size-sm)", margin: 0, maxWidth: width }}>
<figcaption>
<strong>Throughput by region</strong> <span className={Classes.TEXT_MUTED}>Rows per second, UTC</span>
</figcaption>
<div style={{ display: "flex", gap: 16, margin: "8px 0" }}>
{series.map((s) => (
<span key={s.name} style={{ alignItems: "center", display: "inline-flex", gap: 6 }}>
<span style={{ background: s.color, height: 2, width: 12 }} />
{s.name}
</span>
))}
</div>
<svg viewBox={`0 0 ${width} ${height}`} width="100%" className={ClawscaleClasses.NUMERIC} aria-hidden="true">
<g stroke="var(--cs-chart-grid)" shapeRendering="crispEdges">
{ticks.map((tick) => (
<path key={tick} d={`M${left} ${y(tick)}H${right}`} />
))}
</g>
<path d={`M${left} ${baseline}H${right}`} stroke="var(--cs-chart-axis)" shapeRendering="crispEdges" />
<g fill="var(--cs-color-text-muted)">
{ticks.map((tick) => (
<text key={tick} x={left - 8} y={y(tick)} dy="0.32em" textAnchor="end">
{compact.format(tick)}
</text>
))}
{[2, 5, 8, 11].map((at) => (
<text key={at} x={x(at)} y={height - 8} textAnchor="middle">
{hours[at]}
</text>
))}
</g>
{series.map(({ name, color, last, values }) => (
<g key={name}>
<path d={line(values)} fill="none" stroke={color} strokeWidth={2} strokeLinejoin="round" />
<circle cx={right} cy={y(last)} r={4} fill={color} stroke="var(--cs-color-surface)" strokeWidth={2} />
<text x={right + 10} y={y(last)} dy="0.32em" fill="var(--cs-color-text)">
{name}
</text>
{values.map((value, index) => (
<circle key={hours[index]} cx={x(index)} cy={y(value)} r={12} fill="transparent">
<title>{`${name}, ${hours[index]}: ${count.format(value)} rows/s`}</title>
</circle>
))}
</g>
))}
</svg>
<ChartTable
caption="Throughput by region, rows per second"
columns={["Hour (UTC)", ...series.map((s) => s.name)]}
rows={hours.map((hour, index) => [hour, ...series.map((s) => count.format(s.values[index] ?? 0))])}
/>
</figure>
);
}Chart libraries#
SVG libraries such as Recharts accept CSS variables, so stroke="var(--cs-chart-1)" works there too. Canvas renderers such as ECharts, and libraries that compute colors such as Vega, need color values. Read the theme and color scheme with useTheme and pass the matching entry of chartPalettes.
"use client";
import { useTheme } from "@clawscale/react";
import { type BuiltInThemeName, chartPalettes } from "@clawscale/tokens";
export function useChartPalette() {
const { theme, resolvedColorScheme } = useTheme();
return (chartPalettes[theme as BuiltInThemeName] ?? chartPalettes.default)[resolvedColorScheme];
}Index the palette by each series' position in the full list, not in the filtered one, so colors never shift.
// Recharts
const palette = useChartPalette();
<LineChart data={throughput}>
{regions.map((region, index) => (
<Line key={region} dataKey={region} stroke={palette[index]} strokeWidth={2} dot={false} />
))}
</LineChart>;// ECharts
const palette = useChartPalette();
chart.setOption({ color: [...palette], series });// Vega-Lite
const palette = useChartPalette();
const spec = { ...throughputSpec, config: { range: { category: [...palette] } } };Grid, axis and status colors for each theme are in light.chart and dark.chart. The sequential ramp is sequentialBlue. All three come from @clawscale/tokens.