Columns and groups
Chart.Bar draws a column per bucket, on a band scale or on a time scale. Tokens per day, input and output stacked, with the total in the tooltip:
import { createChart } from '@adecore/charts';
import { formatDay, formatTokens } from '@adecore/ui/format';
import { tokensPerDay, type TokenDay } from '../shared/chart-data.ts';
const UsageChart = createChart<TokenDay>();
const days = tokensPerDay();
export default function TokensPerDayDemo() {
return (
<UsageChart.Root data={days} x="day" label="Tokens per day" height={220} className="w-full">
<UsageChart.Grid />
<UsageChart.YAxis format={formatTokens} />
<UsageChart.XAxis format={(day) => formatDay(day as number)} />
<UsageChart.Bars stack>
<UsageChart.Bar value="input" label="Input" />
<UsageChart.Bar value="output" label="Output" />
</UsageChart.Bars>
<UsageChart.Tooltip total />
<UsageChart.Legend />
</UsageChart.Root>
);
}const UsageChart = createChart<TokenDay>();
<UsageChart.Root data={days} x="day" label="Tokens per day" height={220}>
<UsageChart.Grid />
<UsageChart.YAxis format={formatTokens} />
<UsageChart.XAxis format={(day) => formatDay(day as number)} />
<UsageChart.Bars stack>
<UsageChart.Bar value="input" label="Input" />
<UsageChart.Bar value="output" label="Output" />
</UsageChart.Bars>
<UsageChart.Tooltip total />
<UsageChart.Legend />
</UsageChart.Root>Bars and Bar
Chart.Bars holds the Chart.Bar that share the width of a bucket. Without stack they stand side by side, with stack they pile up in the order of the children, with 1px of ground between two segments. A Chart.Bar outside Chart.Bars stands beside the others outside it.
Columns stand on whole pixels. A column is never narrower than 2px, and by default takes 72% of its bucket, at most 56px, which leaves at least 1px of air to the next bucket. While the cursor is on a bucket, the columns of the others dim to 55%.
tone takes a tone, { color }, or a function of the row for a color per column: tone={(row) => runTone(row.status)}. opacity is a function of the row, its index in its group and the size of the group, for columns that grow stronger inside one. The props are ChartBarsProps and ChartBarProps, and ChartBarTone is the type of tone.
| Prop | Type | Default | |
|---|---|---|---|
value, label | Required. What the column reads, and its name in the tooltip, the legend and the table. | ||
tone | ChartBarTone | the next chart color | |
opacity | (row, indexInGroup, groupSize) => number | ||
hoverOpacity | number | 0.55 | The other columns while the cursor is on a bucket; 1 leaves them be. |
dimOpacity | number | 0.4 | A column the root dims, or one not picked while another is. Below 0.4 a column that is already light nearly vanishes in the dark theme. |
columnWidth | (bucket: number) => number | The pixels the columns of a bucket take together, at most the bucket. | |
radius | number | 2 | The corners of a column that stands alone, at most a quarter of its width. |
rounded | 'all' | 'end' | 'all' | Which corners radius rounds: all four, or only the end away from the ground, so the column stands flat on it. |
minHeight | number | The least height of a column that stands alone, in pixels. A zero gets it too, so a run that took no time still shows as a stub; a missing value draws nothing. Pass ?? 0 in value where a missing value should be a stub as well. | |
animate | boolean | false | The columns grow from the ground once, when they first draw. Not for a person who asks for reduced motion. |
valueLabels | boolean | (value, row, index) => string | Writes each column's value over it, see value labels. | |
valueLabelSize | '2xs' | 'xs' | '2xs' | The type size of the value labels. |
Columns over time
On a time scale each bucket lasts one step from its x, and its columns stand in that slot. A marker or a band at any moment then lands where it happened, between the columns or across them, and a row that is missing leaves its slot empty. Runs of a job per hour, the failed ones on top in the error tone, with their sum in the tooltip:
import { Chart } from '@adecore/charts';
import { DEPLOY, GAP, runsPerHour } from '../shared/chart-data.ts';
const runs = runsPerHour();
const HOUR = 3_600_000;
export default function ColumnsOverTimeDemo() {
return (
<Chart.Root data={runs} x="at" xScale="time" step={HOUR} y={{ integer: true }} label="Runs and failures per hour" height={220} className="w-full">
<Chart.Grid />
<Chart.Band from={GAP.start} to={GAP.end} label="No data from the collector" />
<Chart.XAxis />
<Chart.YAxis />
<Chart.Bars stack>
<Chart.Bar value="succeeded" label="Succeeded" />
<Chart.Bar value="failed" label="Failed" tone="error" />
</Chart.Bars>
<Chart.Marker at={DEPLOY.at} label={`Deploy ${DEPLOY.version}`} tone="accent" />
<Chart.Tooltip total="Runs" />
<Chart.Legend />
</Chart.Root>
);
}<Chart.Root data={runs} x="at" xScale="time" step={HOUR} y={{ integer: true }} label="Runs and failures per hour" height={220}>
<Chart.Grid />
<Chart.Band from={gap.start} to={gap.end} label="No data from the collector" />
<Chart.XAxis />
<Chart.YAxis />
<Chart.Bars stack>
<Chart.Bar value="succeeded" label="Succeeded" />
<Chart.Bar value="failed" label="Failed" tone="error" />
</Chart.Bars>
<Chart.Marker at={deploy.at} label={`Deploy ${deploy.version}`} tone="accent" />
<Chart.Tooltip total="Runs" />
<Chart.Legend />
</Chart.Root>step defaults to the smallest distance between two rows; give it when the first rows may be missing. Columns on a time scale take a step: with one row there is no distance to learn it from, and the columns stay away, with a warning outside production. A band scale of days or hours draws the same columns, but there a marker needs the key of a bucket.
Groups
group on the root puts the buckets into groups by a key, in the order of data, with a gap of groupGap pixels between them (16 by default) and half of one at both ends. Each row is one bucket, so a group holds as many columns as it has rows. Scores per model and effort, two to four efforts per model, on a dashed grid, with wider columns that stay as they are while the cursor is elsewhere:
import { useState } from 'react';
import { createChart } from '@adecore/charts';
import { LAB_COLORS, scoresByModel, type ModelScore } from '../shared/chart-data.ts';
const ScoreChart = createChart<ModelScore>();
const scores = scoresByModel();
const SHORT_EFFORTS: Record<string, string> = { low: 'low', medium: 'med', high: 'high', max: 'max' };
/* A stand-in for a lab's logo: its initial on its color. */
function LabMark({ lab }: { lab: string }) {
return (
<span
aria-hidden
className="flex size-5 items-center justify-center rounded-md text-2xs font-semibold text-accent-text"
style={{ background: LAB_COLORS[lab] }}
>
{lab[0]}
</span>
);
}
export default function ScoresByModelDemo() {
const [picked, setPicked] = useState<string[]>(['Lumen Pro:high', 'Atlas 5 mini:high']);
const toggle = (row: ModelScore): void =>
setPicked((current) => (current.includes(row.key) ? current.filter((key) => key !== row.key) : [...current.slice(-1), row.key]));
return (
<ScoreChart.Root
data={scores}
x="key"
group="model"
groupGap={20}
label="Scores per model and effort"
y={{ min: 0, max: 80 }}
height={260}
minBucketWidth={28}
selected={picked}
onActivate={toggle}
className="w-full"
>
<ScoreChart.Grid dashed />
<ScoreChart.YAxis ticks={4} title="Index points, higher is better" />
<ScoreChart.XAxis format={(key) => SHORT_EFFORTS[String(key).split(':')[1]!]!} thin={false} />
<ScoreChart.Bar
value="score"
label="Intelligence"
tone={(row) => ({ color: LAB_COLORS[row.lab]! })}
opacity={(_, indexInGroup, groupSize) => 0.6 + (indexInGroup / Math.max(groupSize - 1, 1)) * 0.4}
hoverOpacity={1}
columnWidth={(bucket) => Math.max(6, Math.min(44, Math.floor(bucket) - 4))}
radius={3}
valueLabels
/>
{picked.map((key, index) => (
<ScoreChart.Rule key={key} value={scores.find((row) => row.key === key)!.score} label={index === 0 ? 'A' : 'B'} dashed={index > 0} />
))}
<ScoreChart.GroupAxis>
{(group) => (
<>
<LabMark lab={group.rows[0]!.lab} />
<span className="leading-tight font-medium text-text">{group.key}</span>
</>
)}
</ScoreChart.GroupAxis>
<ScoreChart.Tooltip />
</ScoreChart.Root>
);
}const ScoreChart = createChart<ModelScore>();
<ScoreChart.Root
data={scores}
x="key"
group="model"
groupGap={20}
label="Scores per model and effort"
y={{ min: 0, max: 80 }}
minBucketWidth={28}
selected={picked}
onActivate={toggle}
>
<ScoreChart.Grid dashed />
<ScoreChart.YAxis ticks={4} title="Index points, higher is better" />
<ScoreChart.XAxis format={(key) => effortOf(key)} thin={false} />
<ScoreChart.Bar
value="score"
label="Intelligence"
tone={(row) => ({ color: labColor(row.lab) })}
opacity={(row, indexInGroup, groupSize) => 0.6 + (indexInGroup / Math.max(groupSize - 1, 1)) * 0.4}
hoverOpacity={1}
columnWidth={(bucket) => Math.max(6, Math.min(44, Math.floor(bucket) - 4))}
radius={3}
valueLabels
/>
<ScoreChart.GroupAxis>
{(group) => (
<>
<LabMark lab={group.rows[0].lab} />
<span>{group.key}</span>
</>
)}
</ScoreChart.GroupAxis>
<ScoreChart.Tooltip />
</ScoreChart.Root>Chart.GroupAxis is a second row of labels under the x axis, one per group and centered under it. It is HTML, so a mark or a logo fits. A label takes the width of its columns plus half the gap on each side before it wraps. Its children is a function of the group (ChartBucketGroup: its key, its label, its rows and the index of its first and last bucket); the props are ChartGroupAxisProps.
The arrow keys keep moving column by column, across the edges of the groups, and Page Up and Page Down jump to the first column of the previous or the next group. The live region names the group first ("Atlas 5, high, Intelligence 71").
The key of a group is its identity, which need not be what a person reads, such as the id of a model. groupLabel on the root reads the name from the rows, as a key or a function: groupLabel="modelName". The tooltip, the live region and the table read it, and Chart.GroupAxis hands it to its children as label.
minBucketWidth is the narrowest a bucket may be. Below it the plot grows wider than its box and scrolls sideways, while the y axis stays in place. What hangs out of the first buckets, such as a value label or a slanted name, may run over the y axis until the plot scrolls.
Picking columns
With onActivate, the buckets become pressable: Enter, Space and a click call it with the row. The buckets in selected stay highlighted, are pressed for a screen reader, and the other columns dim. What a pick means, such as a reference to compare against, stays with the app. dimmed takes a function of the row and dims every column the app does not want in focus, such as the other models while one is highlighted. The labels under a column follow it: they dim with it, at its dimOpacity, and are written in --text while it is under the cursor or picked.
The demo above draws a Chart.Rule at the score of each pick, A solid and B dashed, so the other columns read against them:
{picked.map((key, index) => (
<ScoreChart.Rule key={key} value={scoreOf(key)} label={index === 0 ? 'A' : 'B'} dashed={index > 0} />
))}Compare two columns
A click pins column A. The column under the pointer or the keyboard then stands in for B, with a dashed rule at its value, until a second click pins it. onActiveXChange hears the key of every bucket the pointer or the keyboard moves to, and null when the pointer leaves with no keyboard place to go back to; the app keeps A, B and the preview, and picks both through selected. Here A is pinned on Atlas 5, high; point at another column:
import { useState } from 'react';
import { createChart, type ChartX } from '@adecore/charts';
import { formatNumber } from '@adecore/ui/format';
import { LAB_COLORS, scoresByModel, type ModelScore } from '../shared/chart-data.ts';
const ScoreChart = createChart<ModelScore>();
const scores = scoresByModel();
const EFFORTS: Record<string, string> = { low: 'Low', medium: 'Medium', high: 'High', max: 'Max' };
const SHORT: Record<string, string> = { low: 'L', medium: 'M', high: 'H', max: 'X' };
const scoreOf = (key: string): number => scores.find((row) => row.key === key)!.score;
const signed = (value: number): string => `${value > 0 ? '+' : value < 0 ? '-' : ''}${formatNumber(Math.abs(value))}`;
export default function CompareColumnsDemo() {
const [reference, setReference] = useState<string | null>('Atlas 5:high');
const [candidate, setCandidate] = useState<string | null>(null);
const [preview, setPreview] = useState<string | null>(null);
// A column under the pointer or the keyboard stands in for B until one is picked.
const shown = reference === null ? null : ((preview !== reference ? preview : null) ?? candidate);
const pick = (row: ModelScore): void => {
if (reference === null || row.key === reference) {
setReference(reference === null ? row.key : null);
setCandidate(null);
} else {
setCandidate(row.key);
}
};
return (
<div className="flex w-full flex-col gap-2">
<ScoreChart.Root
data={scores}
x="key"
group="model"
groupGap={20}
rowLabel={(row) => `${row.model}, ${EFFORTS[row.effort]}`}
label="Scores per model and effort, compared"
y={{ min: 0, max: 80 }}
height={240}
minBucketWidth={28}
margin={{ top: 44, right: 16 }}
selected={[reference, shown].filter((key): key is string => key !== null)}
onActivate={pick}
onActiveXChange={(x: ChartX | null) => setPreview(x === null ? null : String(x))}
className="w-full"
>
<ScoreChart.Grid dashed />
<ScoreChart.YAxis ticks={4} />
<ScoreChart.XAxis format={(key) => SHORT[String(key).split(':')[1]!]!} thin={false} />
<ScoreChart.Bar
value="score"
label="Intelligence"
tone={(row) => ({ color: LAB_COLORS[row.lab]! })}
opacity={(_, indexInGroup, groupSize) => 0.6 + (indexInGroup / Math.max(groupSize - 1, 1)) * 0.4}
hoverOpacity={1}
dimOpacity={0.3}
columnWidth={(bucket) => Math.max(6, Math.min(44, Math.floor(bucket) - 4))}
radius={3}
valueLabels
/>
{reference !== null && <ScoreChart.Rule value={scoreOf(reference)} label="A" tone={{ color: 'var(--text-muted)' }} />}
{shown !== null && <ScoreChart.Rule value={scoreOf(shown)} label="B" tone={{ color: 'var(--text-muted)' }} dashed />}
<ScoreChart.GroupAxis>{(group) => <span className="leading-tight font-medium text-text">{group.key}</span>}</ScoreChart.GroupAxis>
</ScoreChart.Root>
<p className="text-xs text-text-muted">
{reference === null
? 'Pick a column to compare against.'
: shown === null
? `A: ${reference.replace(':', ', ')}. Point at another column for B.`
: `B against A: ${signed(scoreOf(shown) - scoreOf(reference))} points.`}
</p>
</div>
);
}const shown = reference === null ? null : ((preview !== reference ? preview : null) ?? candidate);
<ScoreChart.Root
data={scores}
x="key"
group="model"
rowLabel={(row) => `${row.model}, ${effortName(row.effort)}`}
margin={{ top: 44, right: 16 }}
selected={[reference, shown].filter((key) => key !== null)}
onActivate={pick}
onActiveXChange={(x) => setPreview(x === null ? null : String(x))}
>
...
{reference !== null && <ScoreChart.Rule value={scoreOf(reference)} label="A" tone={{ color: 'var(--text-muted)' }} />}
{shown !== null && <ScoreChart.Rule value={scoreOf(shown)} label="B" tone={{ color: 'var(--text-muted)' }} dashed />}
</ScoreChart.Root>rowLabel is what a screen reader hears for a column, "Atlas 5, High", while the axis writes "H". In a dialog, surface="raised" draws the halo of the rule labels in the dialog's surface. Escape clears the cursor first and stops there; an app that clears its pins on Escape too listens for it on an element around the chart.
Few wide columns
A handful of columns, each with its value over it, needs no value axis. lines="ground" on Chart.Grid draws only the line they stand on, rounded="end" rounds the top of a column and keeps it flat on that line, headroom: 'none' lets the tallest fill the plot, and minHeight keeps a short one in sight. The root's format writes the values in the labels, the tooltip and the table without a y axis:
import { Chart } from '@adecore/charts';
import { formatDay, formatDuration } from '@adecore/ui/format';
const WEEK = 7 * 24 * 60 * 60_000;
const HOUR = 60 * 60_000;
const START = Date.UTC(2026, 8, 7);
/* The median wait for a first review per week; the week nobody opened a request has no median. */
const weeks = [5.5, 3.2, 8.1, null, 2.4, 4.7, 6.3, 3.8].map((hours, index) => ({
start: START + index * WEEK,
median: hours === null ? null : hours * HOUR
}));
export default function WeeklyMediansDemo() {
return (
<Chart.Root
data={weeks}
x="start"
label="Median time to a first review per week"
height={140}
y={{ headroom: 'none' }}
format={formatDuration}
rowLabel={(row) => `Week of ${formatDay(row.start)}`}
className="w-full"
>
<Chart.Grid lines="ground" />
<Chart.XAxis format={(start) => formatDay(start as number)} />
<Chart.Bar
value="median"
label="Median"
tone={{ color: 'var(--text-muted)' }}
columnWidth={(bucket) => Math.min(40, bucket - 8)}
radius={4}
rounded="end"
minHeight={2}
valueLabels
valueLabelSize="xs"
/>
<Chart.Tooltip />
<Chart.Table />
</Chart.Root>
);
}<Chart.Root data={weeks} x="start" label="Median time to a first review per week" height={140} y={{ headroom: 'none' }} format={formatDuration}>
<Chart.Grid lines="ground" />
<Chart.XAxis format={(start) => formatDay(start as number)} />
<Chart.Bar value="median" label="Median" radius={4} rounded="end" minHeight={2} valueLabels valueLabelSize="xs" />
<Chart.Tooltip />
</Chart.Root>Runs in a row
A column per run of a job, in the color of how it ended. A missed run took no time, so value reads it as 0 and minHeight keeps it as a stub. The card stays in the top right corner of the plot and moves to the left while the pointer is on a column under it, and cursor="none" lights no bucket, so only the other columns dim:
import { Chart } from '@adecore/charts';
import { formatClock, formatDuration, formatNumber } from '@adecore/ui/format';
const START = Date.UTC(2026, 9, 24, 6);
const STATUSES = { ok: 'Succeeded', error: 'Failed', missed: 'Missed' } as const;
/* The latest runs of a job, oldest first: how long each took, and how it ended. A missed run took no time at all. */
const runs = [42, 38, 51, 47, null, 44, 39, 112, 46, 41, 43, 40, 95, 45, 38, 44].map((seconds, index) => ({
id: `run-${index}`,
at: START + index * 15 * 60_000,
duration: seconds === null ? null : seconds * 1000,
status: seconds === null ? ('missed' as const) : seconds > 90 ? ('error' as const) : ('ok' as const)
}));
export default function RunDurationsDemo() {
return (
<Chart.Root
data={runs}
x="id"
label="Duration of the latest runs"
height={160}
format={formatDuration}
rowLabel={(run) => `${formatClock(run.at)}, ${STATUSES[run.status]}`}
cursor="none"
className="w-full"
>
<Chart.Grid />
<Chart.YAxis ticks={3} format={(ms) => `${formatNumber(ms / 1000)} s`} />
<Chart.Bar
value={(run) => run.duration ?? 0}
label="Duration"
tone={(run) => (run.status === 'error' ? 'error' : run.status === 'missed' ? 'muted' : { color: 'var(--chart-3)' })}
columnWidth={(bucket) => Math.max(2, Math.min(16, bucket - 2))}
radius={0}
minHeight={3}
/>
<Chart.Tooltip placement="top-right" />
</Chart.Root>
);
}<Chart.Root data={runs} x="id" label="Duration of the latest runs" height={160} format={formatDuration} cursor="none">
<Chart.Grid />
<Chart.YAxis ticks={3} format={(ms) => `${formatNumber(ms / 1000)} s`} />
<Chart.Bar value={(run) => run.duration ?? 0} label="Duration" tone={(run) => runTone(run.status)} radius={0} minHeight={3} />
<Chart.Tooltip placement="top-right" />
</Chart.Root>A ranked row
Without group, every column stands in one row. A second Chart.XAxis writes a row of labels under the first, and angle={-40} slants them, for long names under every column:
import { createChart } from '@adecore/charts';
import { LAB_COLORS, scoresByModel, type ModelScore } from '../shared/chart-data.ts';
const ScoreChart = createChart<ModelScore>();
const ranked = scoresByModel().toSorted((one, other) => other.score - one.score);
const SHORT_EFFORTS: Record<string, string> = { low: 'low', medium: 'med', high: 'high', max: 'max' };
export default function ScoresRankedDemo() {
return (
<ScoreChart.Root data={ranked} x="key" label="Scores, ranked" y={{ min: 0, max: 80 }} height={300} minBucketWidth={28} className="w-full">
<ScoreChart.Grid />
<ScoreChart.YAxis ticks={4} />
<ScoreChart.XAxis format={(key) => SHORT_EFFORTS[String(key).split(':')[1]!]!} thin={false} />
<ScoreChart.XAxis angle={-40} format={(key) => String(key).split(':')[0]!} />
<ScoreChart.Bar value="score" label="Intelligence" tone={(row) => ({ color: LAB_COLORS[row.lab]! })} />
<ScoreChart.Tooltip />
</ScoreChart.Root>
);
}<ScoreChart.Root data={ranked} x="key" label="Scores, ranked" y={{ min: 0, max: 80 }} height={300} minBucketWidth={28}>
<ScoreChart.Grid />
<ScoreChart.YAxis ticks={4} />
<ScoreChart.XAxis format={(key) => effortOf(key)} thin={false} />
<ScoreChart.XAxis angle={-40} format={(key) => nameOf(key)} />
<ScoreChart.Bar value="score" label="Intelligence" tone={(row) => ({ color: labColor(row.lab) })} />
<ScoreChart.Tooltip />
</ScoreChart.Root>The rows stack in the order of the children. The tooltip, the live region and the table name a bucket by every row, the lowest first ("Atlas 5, high"). A row of a band scale leaves labels out where they would overlap; thin={false} writes one under every column.
Value labels
valueLabels on Chart.Bar writes each column's value over it, as the y axis writes values, or through a function of the value, the row and its index: valueLabels={(value, row) => row.summary}. The labels are in text-2xs, 10px over their column, which suits a dense row; valueLabelSize="xs" writes them a size larger, for a few wide columns that carry no axis. Where two labels would collide by their measured width, the later one moves up over the other, with a thin line down to its column once it has moved a row. A label dims with its column when the column is dimmed or another is picked, and stays as it is while the cursor is elsewhere. The scores per model above write theirs.
The root keeps 24px above the plot for one row of labels over the tallest column. Labels that move up further need more: set top in margin. The labels are drawn for the eye; the tooltip, the live region and the table carry the same numbers for a screen reader.