Skip to content
Star on GitHub
Charts and tablesBar chart
Style
Accent
Sound

Built by DesignMe, free to use under the MIT licence.

Expert design and dev support for startups and enterprises.

Book a call

Bar 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.

$npx designme add bar-chart
Agent runs0
ShippedNeeded reviewFailed
01

Install

Add it to your project with the CLI.

HTML, CSS and JavaScript

$npx designme add bar-chart

React

$npx designme add bar-chart --react

The 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.

02

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.
03

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>
  );
}
04

Props

Everything BarChart takes.

PropTypeWhat it does
labelrequiredstringThe name over the big number, like "Agent runs"
seriesrequiredBarChartSeries[]What each part of a stack means, in stacking order
rangesrequiredBarChartRange[]One set of bars per range. With more than one, a switch picks between them
rangestringKey of the range shown. Leave it out to let the switch change it
defaultRangestringDefaults to the first range
onRangeChange(key: string) => void
unitstringWhat the values count, like "runs" in "66 runs"
format(n: number) => stringWrites a value. Defaults to whole numbers like 1,240
classNamestring
05

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'));
});
06

Questions

What people ask about it.

How do I change the wording in the hover readout?
Set tipLabel on a series, like "reviewed" for "Needed review". Without it, the readout uses the series label in lower case, and unit follows the bar's total.
Can I use it for a single, non-stacked series?
Yes. Pass one entry in series and 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.
07

More in Charts and tables

Lightweight charts and tables for dashboards and reports, drawn in plain SVG and HTML.

Line chartTwo series over time with a hover readout and toggles.
Donut chartWhere the agent's time went, by kind of task.
Funnel chartSignup to paid, with the biggest drop called out by the agent.
Top pagesMost visited pages with share of traffic and a quick sort.
Health scoreA design system score made of a few checks you can open.
Activity heatmapWhen the agent and the team were busy, by day and hour.
Stat cardsFour headline numbers with sparklines and a period switch.
Account tableCustomer accounts with health, plan and an agent note per row.