Charts and tablesBar chart
Built by DesignMe, free to use under the MIT licence.
Expert design and dev support for startups and enterprises.
Book a callBar chart
Weekly agent runs, split by outcome, with a range switch. Free and MIT licensed, as HTML or a typed React component, in two styles with light and dark mode.
Install
Add it to your project with the CLI.
HTML, CSS and JavaScript
npx designme add bar-chartReact
npx designme add bar-chart --reactThe CLI copies the source into your project, so you own it and can change anything. The HTML version lands in ./designme with the shared core and a demo.html. The React version lands in src/components/designme and needs React 19; it works in Next.js, Vite and Astro.
About
What it is for and what it does.
A stacked bar chart with a headline total, built for counts that split into parts, such as agent runs that shipped, needed review or failed. Hovering a bar shows its total and the value of each part. A switch moves between ranges like Week and Month, and the bars redraw one after another.
- When you want to show how many agent runs happened and how they turned out.
- When each bar is a total made of a few outcomes or categories.
- When the same chart needs a couple of time ranges without a date picker.
- Stacks one value per entry in
seriesin each bar, from the bottom up, coloured from the chart palette or each series'color. - The headline number counts up to the total of every bar in the range on screen.
- Hovering a bar shows its total with
unit, followed by each part named by itstipLabelor lower-cased label. - With more than one entry in
ranges, a segmented switch picks the range and the bars regrow left to right. - A legend under the chart names each series, and
formatcontrols how numbers read.
- The range switch is a role="group" labelled "Range", and its buttons use aria-pressed.
- The count-up of the headline total finishes at once when the user prefers reduced motion.
Usage
Import it and drive it from your own state.
Import
import { BarChart } from '@/components/designme/bar-chart/BarChart';Example
The demo above as a React component, 19 lines you can copy.
'use client';
// The preview from designme.dev, rebuilt from the React component: agent runs by outcome, by week or by month.
import { BarChart } from './BarChart';
const bars = (labels: string[], values: number[][]) => labels.map((label, i) => ({ label, values: values[i] }));
export default function BarChartDemo() {
return (
<div style={{ width: '100%' }}>
<BarChart label="Agent runs" unit="runs"
series={[{ label: 'Shipped' }, { label: 'Needed review', tipLabel: 'reviewed' }, { label: 'Failed' }]}
ranges={[
{ key: 'week', label: 'Week', bars: bars(['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'], [[42, 9, 2], [51, 12, 3], [48, 8, 1], [63, 10, 4], [57, 14, 2], [18, 3, 0], [12, 2, 1]]) },
{ key: 'month', label: 'Month', bars: bars(['W1', 'W2', 'W3', 'W4', 'W5', 'W6', 'W7'], [[180, 40, 9], [210, 44, 12], [236, 51, 8], [262, 48, 10], [251, 39, 6], [298, 52, 7], [321, 47, 5]]) }
]} />
</div>
);
}
Props
Everything BarChart takes.
| Prop | Type | What it does |
|---|---|---|
labelrequired | string | The name over the big number, like "Agent runs" |
seriesrequired | BarChartSeries[] | What each part of a stack means, in stacking order |
rangesrequired | BarChartRange[] | One set of bars per range. With more than one, a switch picks between them |
range | string | Key of the range shown. Leave it out to let the switch change it |
defaultRange | string | Defaults to the first range |
onRangeChange | (key: string) => void | |
unit | string | What the values count, like "runs" in "66 runs" |
format | (n: number) => string | Writes a value. Defaults to whole numbers like 1,240 |
className | string |
Code
The full source, as the CLI installs it.
BarChart.tsxReact · 105 lines
'use client';
import { useRef, useState } from 'react';
import { formatCount, useStagger } from '../lib/chart';
import { cx } from '../lib/cx';
import { useControllable, useIsoLayoutEffect, useTween } from '../lib/hooks';
import { Seg } from '../lib/motion';
import '../designme.css';
import './bar-chart.css';
export interface BarChartSeries {
/** The legend's label, like "Needed review" */
label: string;
/** How the hover readout names it, like "reviewed". Defaults to the label in lower case */
tipLabel?: string;
/** Defaults to the chart palette: blue, purple, then orange */
color?: string;
}
export interface BarChartBar {
/** Under the bar, like "Mon" */
label: string;
/** One value per series, stacked from the bottom */
values: number[];
}
export interface BarChartRange {
key: string;
/** On the switch, like "Week" */
label: string;
bars: BarChartBar[];
}
export interface BarChartProps {
/** The name over the big number, like "Agent runs" */
label: string;
/** What each part of a stack means, in stacking order */
series: BarChartSeries[];
/** One set of bars per range. With more than one, a switch picks between them */
ranges: BarChartRange[];
/** Key of the range shown. Leave it out to let the switch change it */
range?: string;
/** Defaults to the first range */
defaultRange?: string;
onRangeChange?: (key: string) => void;
/** What the values count, like "runs" in "66 runs" */
unit?: string;
/** Writes a value. Defaults to whole numbers like 1,240 */
format?: (n: number) => string;
className?: string;
}
// the stack colours the stylesheet gives the first three parts, used for the legend and beyond three
const PALETTE = ['var(--dm-c1)', 'var(--dm-c2)', 'var(--dm-c4)', 'var(--dm-c3)', 'var(--dm-c5)', 'var(--dm-c6)'];
/** Stacked bars that grow in one after another, with a readout on hover and a switch between ranges. */
export function BarChart({ label, series, ranges, range, defaultRange, onRangeChange, unit, format = formatCount, className }: BarChartProps) {
const [cur, setCur] = useControllable(range, defaultRange ?? ranges[0]?.key ?? '', onRangeChange);
const count = Math.max(0, ...ranges.map(r => r.bars.length));
// the bars draw a moment after the chart appears, then redraw left to right on every switch
const at = useStagger(cur, count, 45, 200);
const byKey = (k: string | null) => ranges.find(r => r.key === k);
const shown = byKey(at[0]);
const sum = (b?: BarChartBar) => b ? b.values.reduce((a, v) => a + v, 0) : 0;
const total = useTween(shown ? shown.bars.reduce((a, b) => a + sum(b), 0) : 0, 900, 0);
// the room the bars have, less the label under them
const ch = useRef<HTMLDivElement>(null);
const [room, setRoom] = useState(128);
useIsoLayoutEffect(() => { if (ch.current) setRoom(ch.current.clientHeight - 22); }, []);
const color = (j: number) => series[j]?.color ?? PALETTE[j % PALETTE.length];
return (
<div data-dm="" className={cx('dm-bcx dm-panel', className)}>
<div className="dm-bcx-h">
<div><span className="dm-muted">{label}</span><b className="dm-num dm-bcx-v">{format(total)}</b></div>
{ranges.length > 1 && (
<Seg className="dm-bcx-rg" label="Range" value={cur} onChange={setCur} options={ranges.map(r => ({ value: r.key, label: r.label }))} />
)}
</div>
<div className="dm-bcx-ch" ref={ch}>
{Array.from({ length: count }, (_, i) => {
const r = byKey(at[i]), bar = r?.bars[i], top = r ? Math.max(...r.bars.map(sum)) * 1.08 : 1;
const tip = shown?.bars[i];
return (
<div key={i} className="dm-bcx-b">
<div className="dm-bcx-tt">
<b className="dm-num">{tip && `${format(sum(tip))}${unit ? ' ' + unit : ''}`}</b>
<span>{tip && tip.values.map((v, j) => `${format(v)} ${series[j]?.tipLabel ?? series[j]?.label.toLowerCase() ?? ''}`).join(', ')}</span>
</div>
<div className="dm-bcx-st">
{series.map((_, j) => {
const v = bar?.values[j] ?? 0;
return <i key={j} style={{ height: bar ? Math.max(v ? 3 : 0, v / top * room) : 0, ...((series[j].color || j > 2) && { background: color(j) }) }} />;
})}
</div>
<div className="dm-bcx-l">{tip?.label}</div>
</div>
);
})}
</div>
<div className="dm-bcx-lg dm-muted">{series.map((s, j) => <span key={j}><i style={{ background: color(j) }} />{s.label}</span>)}</div>
</div>
);
}
bar-chart.cssReact · 27 lines
/* Generated from designme.dev by scripts/sync.mjs. Edit the site, then sync. */
/* ---------- Bar chart ---------- */
.dm-bcx{padding:var(--dm-s-4);display:flex;flex-direction:column;gap:var(--dm-s-3)}
.dm-bcx-h{display:flex;justify-content:space-between;align-items:flex-start;gap:10px}
.dm-bcx-h>div:first-child{display:flex;flex-direction:column;font-size:12.5px}
.dm-bcx-v{font-size:14px;font-weight:500;letter-spacing:-.02em}
.dm-bcx-rg button{font-size:12px;height:26px;padding:0 10px}
.dm-bcx-ch{display:flex;align-items:flex-end;gap:var(--dm-s-2);height:150px;position:relative}
.dm-bcx-b{flex:1;height:100%;display:flex;flex-direction:column;justify-content:flex-end;align-items:stretch;gap:0;position:relative;cursor:default}
.dm-bcx-st{display:flex;flex-direction:column-reverse;gap:2px;border-radius:7px;overflow:hidden;transition:opacity .25s}
.dm-bcx-st i{display:block;height:0;transition:height .7s var(--dm-ease)}
.dm-bcx-st i:nth-child(1){background:var(--dm-c1)}
.dm-bcx-st i:nth-child(2){background:var(--dm-a3)}
.dm-bcx-st i:nth-child(3){background:var(--dm-bad)}
@media (hover:hover) and (pointer:fine){.dm-bcx-ch:hover .dm-bcx-b:not(:hover) .dm-bcx-st{opacity:.4}}
.dm-bcx-l{font-size:11px;color:var(--dm-muted);text-align:center;margin-top:6px;height:14px}
.dm-bcx-tt{position:absolute;bottom:calc(100% + 6px);left:50%;transform:translate(-50%,4px);padding:6px 9px;border-radius:10px;background:var(--dm-ink);color:var(--dm-on-ink);font-size:11.5px;white-space:nowrap;opacity:0;pointer-events:none;transition:opacity .2s,transform .25s var(--dm-ease);z-index:2;display:flex;flex-direction:column}
.dm-bcx-tt span{opacity:.75}
@media (hover:hover) and (pointer:fine){.dm-bcx-b:hover .dm-bcx-tt{opacity:1;transform:translate(-50%,0)}}
.dm-bcx-b:first-child .dm-bcx-tt{left:0;transform:translate(0,4px)}@media (hover:hover) and (pointer:fine){.dm-bcx-b:first-child:hover .dm-bcx-tt{transform:none}}
.dm-bcx-b:last-child .dm-bcx-tt{left:auto;right:0;transform:translate(0,4px)}@media (hover:hover) and (pointer:fine){.dm-bcx-b:last-child:hover .dm-bcx-tt{transform:none}}
/* on a phone the bars are too thin for the second and second-last tips to sit centred without passing the edge */
@container (max-width:400px){.dm-bcx-b:nth-child(2) .dm-bcx-tt{left:0;transform:translate(0,4px)}@media (hover:hover) and (pointer:fine){.dm-bcx-b:nth-child(2):hover .dm-bcx-tt{transform:none}}.dm-bcx-b:nth-last-child(2) .dm-bcx-tt{left:auto;right:0;transform:translate(0,4px)}@media (hover:hover) and (pointer:fine){.dm-bcx-b:nth-last-child(2):hover .dm-bcx-tt{transform:none}}}
.dm-bcx-lg{display:flex;gap:14px;font-size:12px;flex-wrap:wrap}
.dm-bcx-lg span{display:flex;align-items:center;gap:6px}
.dm-bcx-lg i{width:8px;height:8px;border-radius:3px}
bar-chart.htmlHTML · 10 lines
<div class="card" data-c="bar-chart">
<div class="stage-in">
<div class="bcx panel">
<div class="bcx-h"><div><span class="muted">Agent runs</span><b class="num bcx-v">0</b></div><div class="seg2 bcx-rg" role="group" aria-label="Range"><button aria-pressed="true" data-v="w">Week</button><button aria-pressed="false" data-v="m">Month</button></div></div>
<div class="bcx-ch"></div>
<div class="bcx-lg muted"><span><i style="background:var(--c1)"></i>Shipped</span><span><i style="background:var(--a3)"></i>Needed review</span><span><i style="background:var(--bad)"></i>Failed</span></div>
</div>
</div>
</div>
bar-chart.cssHTML · 26 lines
/* ---------- Bar chart ---------- */
.bcx{padding:var(--s-4);display:flex;flex-direction:column;gap:var(--s-3)}
.bcx-h{display:flex;justify-content:space-between;align-items:flex-start;gap:10px}
.bcx-h>div:first-child{display:flex;flex-direction:column;font-size:12.5px}
.bcx-v{font-size:14px;font-weight:500;letter-spacing:-.02em}
.bcx-rg button{font-size:12px;height:26px;padding:0 10px}
.bcx-ch{display:flex;align-items:flex-end;gap:var(--s-2);height:150px;position:relative}
.bcx-b{flex:1;height:100%;display:flex;flex-direction:column;justify-content:flex-end;align-items:stretch;gap:0;position:relative;cursor:default}
.bcx-st{display:flex;flex-direction:column-reverse;gap:2px;border-radius:7px;overflow:hidden;transition:opacity .25s}
.bcx-st i{display:block;height:0;transition:height .7s var(--ease)}
.bcx-st i:nth-child(1){background:var(--c1)}
.bcx-st i:nth-child(2){background:var(--a3)}
.bcx-st i:nth-child(3){background:var(--bad)}
@media (hover:hover) and (pointer:fine){.bcx-ch:hover .bcx-b:not(:hover) .bcx-st{opacity:.4}}
.bcx-l{font-size:11px;color:var(--muted);text-align:center;margin-top:6px;height:14px}
.bcx-tt{position:absolute;bottom:calc(100% + 6px);left:50%;transform:translate(-50%,4px);padding:6px 9px;border-radius:10px;background:var(--ink);color:var(--on-ink);font-size:11.5px;white-space:nowrap;opacity:0;pointer-events:none;transition:opacity .2s,transform .25s var(--ease);z-index:2;display:flex;flex-direction:column}
.bcx-tt span{opacity:.75}
@media (hover:hover) and (pointer:fine){.bcx-b:hover .bcx-tt{opacity:1;transform:translate(-50%,0)}}
.bcx-b:first-child .bcx-tt{left:0;transform:translate(0,4px)}@media (hover:hover) and (pointer:fine){.bcx-b:first-child:hover .bcx-tt{transform:none}}
.bcx-b:last-child .bcx-tt{left:auto;right:0;transform:translate(0,4px)}@media (hover:hover) and (pointer:fine){.bcx-b:last-child:hover .bcx-tt{transform:none}}
/* on a phone the bars are too thin for the second and second-last tips to sit centred without passing the edge */
@container (max-width:400px){.bcx-b:nth-child(2) .bcx-tt{left:0;transform:translate(0,4px)}@media (hover:hover) and (pointer:fine){.bcx-b:nth-child(2):hover .bcx-tt{transform:none}}.bcx-b:nth-last-child(2) .bcx-tt{left:auto;right:0;transform:translate(0,4px)}@media (hover:hover) and (pointer:fine){.bcx-b:nth-last-child(2):hover .bcx-tt{transform:none}}}
.bcx-lg{display:flex;gap:14px;font-size:12px;flex-wrap:wrap}
.bcx-lg span{display:flex;align-items:center;gap:6px}
.bcx-lg i{width:8px;height:8px;border-radius:3px}
bar-chart.jsHTML · 19 lines
DM.def('bar-chart', (r, c) => {
const D = {
w:{ l:['Mon','Tue','Wed','Thu','Fri','Sat','Sun'], d:[[42,9,2],[51,12,3],[48,8,1],[63,10,4],[57,14,2],[18,3,0],[12,2,1]] },
m:{ l:['W1','W2','W3','W4','W5','W6','W7'], d:[[180,40,9],[210,44,12],[236,51,8],[262,48,10],[251,39,6],[298,52,7],[321,47,5]] }
};
const ch = $('.bcx-ch', r), v = $('.bcx-v', r); let tot = 0;
ch.innerHTML = D.w.l.map(() => `<div class="bcx-b"><div class="bcx-tt"><b class="num"></b><span></span></div><div class="bcx-st"><i></i><i></i><i></i></div><div class="bcx-l"></div></div>`).join('');
const bars = $$('.bcx-b', ch);
function draw(k){
const d = D[k], mx = Math.max(...d.d.map(x => x[0] + x[1] + x[2])) * 1.08, H = ch.clientHeight - 22;
bars.forEach((b, i) => { const x = d.d[i], s = x[0] + x[1] + x[2];
c.later(i * 45, () => $$('.bcx-st i', b).forEach((e, j) => e.style.height = Math.max(x[j] ? 3 : 0, x[j] / mx * H) + 'px'));
$('.bcx-l', b).textContent = d.l[i]; $('.bcx-tt b', b).textContent = s + ' runs'; $('.bcx-tt span', b).textContent = `${x[0]} shipped, ${x[1]} reviewed, ${x[2]} failed`; });
const t = d.d.reduce((a, x) => a + x[0] + x[1] + x[2], 0); tween(tot, t, 900, n => { tot = n; v.textContent = Math.round(n).toLocaleString('en-US'); });
}
seg($('.bcx-rg', r), draw);
c.later(280, () => draw('w'));
});
Questions
What people ask about it.
- How do I change the wording in the hover readout?
- Set
tipLabelon a series, like "reviewed" for "Needed review". Without it, the readout uses the series label in lower case, andunitfollows the bar's total. - Can I use it for a single, non-stacked series?
- Yes. Pass one entry in
seriesand one value per bar. Each bar is then a single block. - How do I hide the range switch?
- Pass a single entry in
ranges. The switch only appears when there is more than one. - Do the ranges need the same number of bars?
- No. The chart makes room for the longest range, and bars a range does not have stay empty.
More in Charts and tables
Lightweight charts and tables for dashboards and reports, drawn in plain SVG and HTML.