Skip to content
Caseroom

Components · Data

Bar chart

Columns with their values on top and labels under them; the same data in a table for screen readers.

0.92% of the 2,062 sites in The UI Index have a chart (19 sites).

Deploys per month235 in six months
Deploys per month
LabelValue
Jun24 deploys
Jul31 deploys
Aug18 deploys
Sep42 deploys
Oct57 deploys
Nov63 deploys

Both previews render the same markup; the HTML tab below is that markup without a framework.

Install

React
npx shadcn@latest add https://www.themes.dev/caseroom/r/bar-chart.json
Astro
npx shadcn@latest add https://www.themes.dev/caseroom/r/astro/bar-chart.json

Or copy the files below into your project. They need clsx, tailwind-merge, and the Caseroom theme. The HTML needs only the compiled CSS.

bar-chart.tsx
import type { CSSProperties } from 'react';
import { cn } from '@/lib/utils';
import { barChart as c, heightOf, type Bar } from './bar-chart.variants';

export type { Bar };

export interface BarChartProps {
  title: string;
  data: Bar[];
  /** Shown after numbers, e.g. " deploys" or "%". */
  unit?: string;
  /** A line at the top right, e.g. "Total 412". */
  summary?: string;
  className?: string;
}

/** Columns with their values on top, labels under them, and the same data in a table for screen readers. */
export function BarChart({ title, data, unit = '', summary, className }: BarChartProps) {
  const max = Math.max(...data.map((d) => d.value));
  return (
    <figure className={cn(c.root, className)} style={{ '--bars': data.length } as CSSProperties}>
      <figcaption className={c.head}>
        <span className={c.title}>{title}</span>
        {summary && <span className={c.total}>{summary}</span>}
      </figcaption>
      <div aria-hidden="true">
        <div className={c.plot}>
          {data.map((d) => (
            <div key={d.label} className={c.col}>
              <span className={c.bar} style={{ height: heightOf(d.value, max) }}><span className={c.value}>{d.value}</span></span>
            </div>
          ))}
        </div>
        <div className={c.labels}>{data.map((d) => <span key={d.label} className={c.label}>{d.label}</span>)}</div>
      </div>
      <table className="sr-only">
        <caption>{title}</caption>
        <thead><tr><th scope="col">Label</th><th scope="col">Value</th></tr></thead>
        <tbody>{data.map((d) => <tr key={d.label}><th scope="row">{d.label}</th><td>{d.value}{unit}</td></tr>)}</tbody>
      </table>
    </figure>
  );
}
bar-chart.variants.ts
/** A bar chart in HTML and CSS: columns grow from a baseline to their share of the largest value, each with its
 *  value on top and its label under it, so the numbers are read, not guessed. Ink when an accent is on (primary),
 *  otherwise black. The same data is in a table for screen readers; the drawing is hidden from them. The columns
 *  rise once on load; under reduced motion they stand still. */
export const barChart = {
  root: 'w-full',
  head: 'flex items-baseline justify-between gap-4',
  title: 'text-sm font-medium',
  total: 'text-sm text-muted-foreground tabular-nums',
  // The value sits above its bar (absolute), so a bar's height is its share of the whole column.
  plot: 'mt-6 grid h-48 items-end gap-2 border-b pt-6 [grid-template-columns:repeat(var(--bars),minmax(0,1fr))]',
  col: 'group flex h-full flex-col items-center justify-end',
  value: 'absolute -top-5 left-1/2 -translate-x-1/2 text-xs text-muted-foreground tabular-nums transition-colors group-hover:text-foreground',
  bar: 'relative w-full max-w-10 origin-bottom rounded-t-sm bg-primary transition-[opacity,scale] group-hover:opacity-85 animate-[caseroom-rise_600ms_ease-out_both] motion-reduce:animate-none',
  labels: 'mt-2 grid gap-2 [grid-template-columns:repeat(var(--bars),minmax(0,1fr))]',
  label: 'truncate text-center text-xs text-muted-foreground',
};
export type Bar = { label: string; value: number };
export const heightOf = (value: number, max: number) => `${Math.max(2, Math.round((value / (max || 1)) * 100))}%`;