Skip to content
goodtabs UI
BrowseBrowse components

Bar chart

A plain SVG bar chart with a labelled axis, hairline grid and a text alternative.

npx shadcn@latest add https://ui.goodtabs.co/r/bar-chart.json

With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/bar-chart

Installs by weekInstalls: W1 420, W2 610, W3 540, W4 780, W5 1,020.
Installs by weekInstalls

Foreground bars with one brand mark for the latest week.

Twelve months

Page views by month, 2026Views: Jan 3.2k, Feb 4.1k, Mar 3.9k, Apr 5.2k, May 6.1k, Jun 5.8k, Jul 7.4k, Aug 7k, Sep 8.3k, Oct 9.1k, Nov 9.8k, Dec 12.4k.
Page views by month, 2026Views

A custom tick format and a wider chart.

Usage

import { BarChart } from "@/components/ui/bar-chart"

<BarChart title="Installs by week" axisLabel="Installs" data={data} highlight={4} />

Props

PropTypeDefaultDescription
data{ label: string; value: number }[]One bar per entry, in order.
titlestringNames the chart, shown as caption and SVG title.
axisLabelstringWhat the vertical axis measures.
highlightnumberIndex of the one bar drawn in the brand colour.
format(value: number) => stringFormats ticks and the text alternative.

Source

bar-chart/bar-chart.tsx
import * as React from "react";
import { cn } from "@/lib/utils";

export type BarDatum = { label: string; value: number };

type BarChartProps = Omit<React.ComponentProps<"figure">, "children" | "title"> & {
  data: BarDatum[];
  /** Names the chart. Also the SVG title. */
  title: string;
  /** Label for the vertical axis, for example "Installs". */
  axisLabel: string;
  /** Index of the one mark drawn in the brand colour. */
  highlight?: number;
  /** Formats axis ticks and bar values. Defaults to en-US grouping. */
  format?: (value: number) => string;
};

const W = 320;
const H = 180;
const M = { top: 12, right: 8, bottom: 24, left: 36 };

/** Pick a step from 1, 2, 5 times a power of ten so that max / step is at most 4. */
function niceStep(max: number) {
  const raw = max / 4;
  const pow = 10 ** Math.floor(Math.log10(raw));
  return ([1, 2, 5, 10].find((m) => m * pow >= raw) ?? 10) * pow;
}

const group = (n: number) => new Intl.NumberFormat("en-US").format(n);

/**
 * A vertical bar chart in plain SVG. Foreground tones with hairline gridlines,
 * one optional brand mark. The SVG has a title and a description that lists every value.
 */
export function BarChart({ data, title, axisLabel, highlight, format = group, className, ...props }: BarChartProps) {
  const id = React.useId();
  const step = niceStep(Math.max(...data.map((d) => d.value), 1));
  const top = Math.ceil(Math.max(...data.map((d) => d.value), 1) / step) * step;
  const ticks = Array.from({ length: top / step + 1 }, (_, i) => i * step);
  const plotW = W - M.left - M.right;
  const plotH = H - M.top - M.bottom;
  const y = (v: number) => M.top + plotH - (v / top) * plotH;
  const slot = plotW / data.length;
  const bar = Math.min(slot * 0.6, 28);
  const summary = `${axisLabel}: ${data.map((d) => `${d.label} ${format(d.value)}`).join(", ")}.`;

  return (
    <figure data-slot="bar-chart" className={cn("m-0 w-full", className)} {...props}>
      <svg
        viewBox={`0 0 ${W} ${H}`}
        role="img"
        aria-labelledby={`${id}-t ${id}-d`}
        className="block h-auto w-full text-foreground"
      >
        <title id={`${id}-t`}>{title}</title>
        <desc id={`${id}-d`}>{summary}</desc>
        <g aria-hidden="true">
          {ticks.map((t) => (
            <g key={t}>
              <line
                x1={M.left}
                x2={W - M.right}
                y1={y(t)}
                y2={y(t)}
                stroke="currentColor"
                strokeOpacity={t === 0 ? 0.3 : 0.1}
              />
              <text
                x={M.left - 6}
                y={y(t)}
                dy="0.32em"
                textAnchor="end"
                className="fill-muted-foreground font-mono text-[9px]"
              >
                {format(t)}
              </text>
            </g>
          ))}
          {data.map((d, i) => {
            const cx = M.left + slot * i + slot / 2;
            const h = (d.value / top) * plotH;
            return (
              <g key={d.label}>
                <rect
                  x={cx - bar / 2}
                  y={y(d.value)}
                  width={bar}
                  height={h}
                  rx={2}
                  fill={i === highlight ? "var(--brand)" : "currentColor"}
                  fillOpacity={i === highlight ? 1 : 0.55}
                />
                <text x={cx} y={H - 8} textAnchor="middle" className="fill-muted-foreground font-mono text-[9px]">
                  {d.label}
                </text>
              </g>
            );
          })}
        </g>
      </svg>
      <figcaption className="mt-1 flex items-center justify-between text-xs text-muted-foreground">
        <span>{title}</span>
        <span className="font-mono">{axisLabel}</span>
      </figcaption>
    </figure>
  );
}

For AI agents

Hand your agent this page, the markdown at /components/bar-chart/prompt.md, or the Copy AI prompt button above.

When to use

  • Comparing a handful of values across categories or periods
  • A dashboard card that needs a real axis

When not to use

  • Many series or interactive tooltips: use a chart library
  • A single number: use a stat card

Agent notes

  • Plain SVG, server rendered, no chart library
  • The SVG has role img, a title and a description that lists every label and value
  • Bars start at zero. Values must be zero or above
  • Keep the label short; at twelve bars the label is about 20px wide
  • Brand is used for at most one bar, through highlight

The maths

  • The axis step is the smallest of 1, 2, 5 or 10 times a power of ten that is at least max / 4, so there are at most four gridline intervals.
  • The top is ceil(max / step) × step, and a bar's height is value / top × plot height.
  • Bar width is min(0.6 × slot, 28) where slot is plot width / bar count.