Bar Chart

Categories compared by length, grouped or stacked.

'use client';

import { useState } from 'react';
import { BarChart, type BarChartDatum } from '@/components/panelui/bar-chart';
import { Frame } from '@/components/panelui/frame';

const REVENUE = [
  { month: 'Jan', revenue: 18_400 },
  { month: 'Feb', revenue: 21_200 },
  { month: 'Mar', revenue: 19_800 },
  { month: 'Apr', revenue: 24_600 },
  { month: 'May', revenue: 27_100 },
  { month: 'Jun', revenue: 25_300 },
  { month: 'Jul', revenue: 29_900 },
  { month: 'Aug', revenue: 32_400 },
];

const total = REVENUE.reduce((sum, row) => sum + row.revenue, 0);
const dollars = (value: number) => `$${value.toLocaleString('en-US')}`;

export default function BarChartOverview() {
  const [active, setActive] = useState<BarChartDatum | null>(null);

  return (
    <Frame variant="band" className="w-full max-w-lg">
      <Frame.Header>
        <Frame.Title>Revenue</Frame.Title>
        <Frame.Action>2026</Frame.Action>
      </Frame.Header>
      <Frame.Panel className="p-5">
        <BarChart data={REVENUE} xDataKey="month" onActiveIndexChange={(_index, datum) => setActive(datum)}>
          <BarChart.Header
            title={active ? String(active.month) : 'January to August'}
            value={dollars(active ? Number(active.revenue) : total)}
          />
          <BarChart.Grid />
          <BarChart.Bar dataKey="revenue" />
          <BarChart.YAxis />
          <BarChart.XAxis />
          <BarChart.Tooltip labels={{ revenue: 'Revenue' }} formatValue={dollars} />
        </BarChart>
      </Frame.Panel>
    </Frame>
  );
}

Use it to compare categories by length: revenue per month, messages per channel. Each row of data is a band, and each BarChart.Bar is a series drawn from one of its keys.

The grid, the axes, the legend and the readout are separate parts, so a chart has only the ones you write.

The value axis always includes zero, because a bar cropped at the bottom misstates its value. For a series where the change matters more than the size, use a line chart instead.

Installation

npx shadcn@latest add https://panelui.dev/r/web/bar-chart.json

The source is copied into components/panelui/, where you can edit it.

Usage

import { BarChart } from "@/components/panelui/bar-chart"
<BarChart data={revenue} xDataKey="month">
  <BarChart.Grid />
  <BarChart.Bar dataKey="revenue" />
  <BarChart.XAxis />
  <BarChart.Tooltip />
</BarChart>

Composition

<BarChart data={…}>
  <BarChart.Header />
  <BarChart.Grid />
  <BarChart.Bar dataKey="…" />
  <BarChart.Skeleton />
  <BarChart.XAxis />
  <BarChart.YAxis />
  <BarChart.Legend />
  <BarChart.Tooltip />
</BarChart>
  • BarChart.Header — The strip above the plot: title, value and caption, with the series key or your own control on the trailing side.
  • BarChart.Grid — Lines across the value axis, in the theme's border colour. rows is roughly how many: the axis picks round numbers near it.
  • BarChart.Bar — One series. Declare one per key. Their order is the order they stand in a band, and from the baseline up in a stack.
  • BarChart.Skeleton — A row of short, equal stubs on the baseline with a highlight moving across them, drawn only while status is loading.
  • BarChart.XAxis — Category labels under the bands. Labels are dropped when the bands are too narrow to fit them. Draws nothing on a horizontal chart.
  • BarChart.YAxis — Value labels on the grid lines, in round numbers, with room made for them. On a horizontal chart it draws the category names instead.
  • BarChart.Legend — A swatch and a name per series, over the top corner of the plot. On a chart with a header, use the header's legend prop instead.
  • BarChart.Tooltip — The readout, and what drives it. It covers the plot, so the pointer does not have to land on a bar. labels names the series in it.

Examples

Two series side by side

Declare a Bar per series. They share the width barGap leaves in each band. color names one of the theme's roles.

  • month, Mar
  • month, Apr
  • month, May
  • month, Jun
  • month, Jul
  • month, Aug
'use client';

import { BarChart } from '@/components/panelui/bar-chart';

const REVENUE = [
  { month: 'Mar', revenue: 19_800, costs: 12_100 },
  { month: 'Apr', revenue: 24_600, costs: 13_400 },
  { month: 'May', revenue: 27_100, costs: 14_900 },
  { month: 'Jun', revenue: 25_300, costs: 15_200 },
  { month: 'Jul', revenue: 29_900, costs: 16_800 },
  { month: 'Aug', revenue: 32_400, costs: 17_300 },
];

export default function BarChartGrouped() {
  return (
    <BarChart data={REVENUE} xDataKey="month" className="max-w-lg">
      <BarChart.Grid />
      <BarChart.Bar dataKey="revenue" />
      <BarChart.Bar dataKey="costs" color="chart-3" />
      <BarChart.XAxis />
      <BarChart.Legend labels={{ revenue: 'Revenue', costs: 'Costs' }} />
      <BarChart.Tooltip
        labels={{ revenue: 'Revenue', costs: 'Costs' }}
        formatValue={(value) => `$${value.toLocaleString('en-US')}`}
      />
    </BarChart>
  );
}

Stacked

stacked adds the series into a total, so give it series that add up, such as costs and profit. stackGap leaves a hairline between the segments, which keeps similar colours apart. The first Bar is the lowest segment.

  • month, Mar
  • month, Apr
  • month, May
  • month, Jun
  • month, Jul
  • month, Aug
'use client';

import { BarChart } from '@/components/panelui/bar-chart';

const REVENUE = [
  { month: 'Mar', costs: 12_100, profit: 7_700 },
  { month: 'Apr', costs: 13_400, profit: 11_200 },
  { month: 'May', costs: 14_900, profit: 12_200 },
  { month: 'Jun', costs: 15_200, profit: 10_100 },
  { month: 'Jul', costs: 16_800, profit: 13_100 },
  { month: 'Aug', costs: 17_300, profit: 15_100 },
];

export default function BarChartStacked() {
  return (
    <BarChart data={REVENUE} xDataKey="month" stacked stackGap={2} className="max-w-lg">
      <BarChart.Grid />
      <BarChart.Bar dataKey="costs" color="chart-3" />
      <BarChart.Bar dataKey="profit" />
      <BarChart.XAxis />
      <BarChart.Tooltip labels={{ costs: 'Costs', profit: 'Profit' }} />
    </BarChart>
  );
}

Sideways

Use orientation="horizontal" when the category names are longer than a band is wide. YAxis draws the names down the side.

  • name, Newsletter
  • name, Push
  • name, Product
  • name, Referrals
  • name, Search
'use client';

import { BarChart } from '@/components/panelui/bar-chart';

const CHANNELS = [
  { name: 'Newsletter', sent: 4_200 },
  { name: 'Push', sent: 3_100 },
  { name: 'Product', sent: 2_400 },
  { name: 'Referrals', sent: 1_800 },
  { name: 'Search', sent: 900 },
];

export default function BarChartSideways() {
  return (
    <BarChart data={CHANNELS} orientation="horizontal" barGap={0.35} className="max-w-lg">
      <BarChart.Grid />
      <BarChart.Bar dataKey="sent" color="chart-2" />
      <BarChart.YAxis />
      <BarChart.Tooltip labels={{ sent: 'Sent' }} />
    </BarChart>
  );
}

Values too small to see

minBarLength draws any non-zero bar at a visible length. Thursday's zero still draws nothing.

  • day, Mon
  • day, Tue
  • day, Wed
  • day, Thu
  • day, Fri
  • day, Sat
  • day, Sun
'use client';

import { BarChart } from '@/components/panelui/bar-chart';

const ERRORS = [
  { day: 'Mon', count: 1 },
  { day: 'Tue', count: 212 },
  { day: 'Wed', count: 4 },
  { day: 'Thu', count: 0 },
  { day: 'Fri', count: 96 },
  { day: 'Sat', count: 2 },
  { day: 'Sun', count: 1 },
];

export default function BarChartSmallValues() {
  return (
    <BarChart data={ERRORS} xDataKey="day" minBarLength={3} className="max-w-lg">
      <BarChart.Bar dataKey="count" color="destructive" />
      <BarChart.XAxis />
      <BarChart.Tooltip labels={{ count: 'Errors' }} />
    </BarChart>
  );
}

Loading, then data

Add BarChart.Skeleton and drive status. The bars grow out of the baseline when it turns ready. It draws one stub per row, or seven with no rows yet; bars sets another count.

'use client';

import { useState } from 'react';
import { BarChart, type BarChartStatus } from '@/components/panelui/bar-chart';
import { Button } from '@/components/panelui/button';

const REVENUE = [
  { month: 'Mar', revenue: 19_800 },
  { month: 'Apr', revenue: 24_600 },
  { month: 'May', revenue: 27_100 },
  { month: 'Jun', revenue: 25_300 },
  { month: 'Jul', revenue: 29_900 },
  { month: 'Aug', revenue: 32_400 },
];

export default function BarChartLoading() {
  const [status, setStatus] = useState<BarChartStatus>('loading');

  return (
    <div className="flex w-full max-w-lg flex-col items-start gap-4">
      <BarChart data={REVENUE} status={status} xDataKey="month">
        <BarChart.Grid />
        <BarChart.Skeleton />
        <BarChart.Bar dataKey="revenue" />
        <BarChart.XAxis />
      </BarChart>
      <Button variant="outline" onClick={() => setStatus(status === 'loading' ? 'ready' : 'loading')}>
        {status === 'loading' ? 'Finish loading' : 'Load again'}
      </Button>
    </div>
  );
}

Changing data

When data changes, the bars and the axis move to their new values over domainDuration, and a bar under the same band and series keeps its identity. A ref on the chart has replay(), which grows the bars in again.

'use client';

import { useRef, useState } from 'react';
import { BarChart, type BarChartHandle } from '@/components/panelui/bar-chart';
import { Button } from '@/components/panelui/button';

const MONTHS = ['Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug'];
const PERIODS = {
  'This year': [19_800, 24_600, 27_100, 25_300, 29_900, 32_400],
  'Last year': [9_200, 11_500, 10_400, 13_800, 12_900, 15_100],
};
type Period = keyof typeof PERIODS;

export default function BarChartChangingData() {
  const chart = useRef<BarChartHandle>(null);
  const [period, setPeriod] = useState<Period>('This year');
  const rows = MONTHS.map((month, index) => ({ month, revenue: PERIODS[period][index] }));

  return (
    <div className="flex w-full max-w-lg flex-col items-start gap-4">
      <BarChart ref={chart} data={rows} xDataKey="month">
        <BarChart.Grid />
        <BarChart.Bar dataKey="revenue" />
        <BarChart.YAxis />
        <BarChart.XAxis />
        <BarChart.Tooltip labels={{ revenue: period }} />
      </BarChart>
      <div className="flex gap-2">
        <Button variant="outline" onClick={() => setPeriod(period === 'This year' ? 'Last year' : 'This year')}>
          Show {period === 'This year' ? 'last year' : 'this year'}
        </Button>
        <Button variant="ghost" onClick={() => chart.current?.replay()}>
          Replay
        </Button>
      </div>
    </div>
  );
}

API Reference

BarChart

PropTypeDefaultDescription
datareadonly BarChartDatum[]—The rows. Each one is a band along the category axis.
xDataKeystring'name'Key holding the category label. Used by the axis and the readout.
statusBarChartStatus'ready'loading holds the bars back and grows them in when it turns ready. Add a BarChart.Skeleton for something to stand in the plot meanwhile.
aspectRationumber2The plot's width ÷ height. 2 is the wide card shape.
animationDurationnumber700Milliseconds for the bars to grow in.
domainDurationnumber500Milliseconds for the bars and the axis to settle after the data changes.
yDomain[number, number]—Fix the value axis instead of deriving it. The derived domain always includes zero, and bar lengths cannot be compared on a domain that does not.
orientationBarChartOrientation'vertical'vertical grows the bars upward; horizontal grows them rightward.
stackedbooleanfalseStack the series on each other instead of standing them side by side.
barGapnumber0.2Fraction of each band left empty, 0 to 1.
barWidthnumber—Largest bar thickness in pixels. Derived from the band when omitted.
stackGapnumber0Pixels between the segments of a stack.
cornerRadiusnumber4Corner radius on the growing end of a bar.
minBarLengthnumber0Smallest length a non-zero bar is drawn at, in pixels.
fadedOpacitynumber0.3Opacity of the bars that are not being read.
onActiveIndexChange(index: number, datum: BarChartDatum | null) => void—The band being read, and -1/null when there is none. Fires when the index changes, not per frame.
compactbooleanfalseDrop the axis padding, for a bar sparkline with no axis or readout.
labelstring—What a screen reader calls the chart.
labelForDatum(datum: BarChartDatum, index: number) => string—What a screen reader says for one row. Defaults to its keys and values.

BarChart.Grid

PropTypeDefaultDescription
rowsnumber—Roughly how many lines to draw across the value axis.
dashArraystring—The stroke's dash pattern.
opacitynumber—Opacity of the lines.

BarChart.Bar

PropTypeDefaultDescription
dataKeystring—Column in the data holding this series' values.
colorBarChartColor—Which of the theme's roles the series is drawn in.
cornerRadiusnumber4Corner radius, overriding the chart's.

BarChart.Skeleton

PropTypeDefaultDescription
barsnumber—How many placeholder bars to draw. Defaults to one per row, and to seven when the data has not arrived.
durationnumber—Milliseconds for one pass of the sweep.

BarChart.XAxis

PropTypeDefaultDescription
ticksnumber—How many labels to show. Every category by default, thinned only when the bands get too narrow to read. Pass a number to force it lower.
format(datum: BarChartDatum, index: number) => string—Turn a row into its label. Defaults to the value at xDataKey.

BarChart.YAxis

PropTypeDefaultDescription
ticksnumber—Roughly how many labels to show along the value axis.
format(value: number) => string—Format a value for its label. Defaults to a compact number.

BarChart.Tooltip

PropTypeDefaultDescription
formatValue(value: number, key: string) => string—Format one series' value. Defaults to a compact number.
formatX(datum: BarChartDatum) => string—Format the readout's heading from the row. Defaults to the value at xDataKey.
labelsRecord<string, string>—Prettier names for the series keys, as the legend takes.
classNamestring—Added to the readout's surface.

BarChart.Legend

PropTypeDefaultDescription
labelsRecord<string, string>—Prettier names for the series keys.

BarChart.Header

PropTypeDefaultDescription
titleReactNode—Small line above the value: what the chart is of.
valueReactNode—The readout. The largest thing on the card, and the first thing read.
captionReactNode—One muted line under the value: a period, a comparison, a total.
labelsRecord<string, string>—Prettier names for the series keys, as the legend takes.
legendbooleanfalseDraw a swatch and a name per series along the trailing edge. Prefer this to BarChart.Legend on a chart that has a header: the legend sits over the plot, in the corner the bars also want.

BarChart, BarChart.Header and BarChart.Legend also take the props of the element they render, and a className. BarChart.Tooltip puts its className on the readout. The other parts draw nothing themselves and take only the props listed.

Notes

Sizing

The chart fills its container's width and takes its height from aspectRatio, which applies to the plot alone, so a header does not shrink it. The plot draws in the browser after it has been measured, and the space is reserved until then, so it is not in server-rendered HTML.

Bundle size

The chart adds about 60 KB of gzipped JavaScript to a page that uses it. A page that does not import it pays nothing, including one that imports other components from the package root.

Colour

color on BarChart.Bar names a role in the theme. Every series defaults to chart-1, so give the others their own.

Reading a band

With a Tooltip, hovering a band reads it. A click or a tap pins the reading until the next press elsewhere, or Escape. On a touch screen a sideways drag reads the bands it crosses, and a vertical drag scrolls the page.

onActiveIndexChange fires when the band changes, not on every frame, so it can drive a readout outside the chart, as the first example does. Inside the chart, useBarChart gives the same band.

Keyboard

With a Tooltip, the plot takes focus. The arrow keys move between bands, Home and End go to the first and last, Enter or Space pins the reading, and Escape clears it.

Screen readers

Each row is given as text beside the drawing. label names the chart and labelForDatum writes a row, for values with a unit. Without them a row is read as its keys and values.

Reduced motion

With reduced motion set, the bars and the axis appear at their final values, and the skeleton's highlight is not drawn.

Server Components

BarChart is a client component. In a file that starts with "use client", write its parts with a dot, as on this page. A Server Component cannot read BarChart.Header off the import, so each part is also exported by name: BarChartHeader is BarChart.Header, and so on for the others.

import { BarChart, BarChartHeader, BarChartGrid, BarChartBar, BarChartSkeleton, BarChartXAxis, BarChartYAxis, BarChartTooltip, BarChartLegend } from "@/components/panelui/bar-chart"

On this page