Charts

Bar Chart

High-level bar chart wrapper over the Chart primitive supporting grouped, stacked, and percent layouts with a teal-led quebi palette and an interactive legend. Requires the recharts package.

chartbar-chartrechartsdatagraphvisualization

Grouped

Two teal-led series rendered side by side. Click a legend item to focus a single series.

Stacked

The same series stacked into a single bar per category.

Percent (100%)

A stacked-to-100% layout that shows each series as a share of the total.

Single series, no legend

A minimal bar chart with one series, the legend hidden, and grid lines removed.

Source

Copy this into your project. Resolve its dependencies from the registryDependencies in the component's API entry.

"use client"

import { type ComponentProps, startTransition, useMemo } from "react"
import { Bar, BarChart as BarChartPrimitive } from "recharts"
import type { NameType, ValueType } from "recharts/types/component/DefaultTooltipContent"
import {
  type BaseChartProps,
  CartesianGrid,
  Chart,
  ChartLegend,
  ChartLegendContent,
  ChartTooltip,
  ChartTooltipContent,
  constructCategoryColors,
  DEFAULT_COLORS,
  getColorValue,
  valueToPercent,
  XAxis,
  YAxis,
} from "@/components/chart"

/**
 * BarChart — quebi design system
 *
 * A high-level wrapper around the `Chart` primitive that renders grouped,
 * stacked, or percent (100%) bar charts from a `config` + `data` pair. Bars
 * use the teal-led quebi series palette, with an interactive legend that
 * focuses a single series on click. Pass `children` to fully control the bars.
 *
 * Requires the `recharts` npm package as a peer dependency.
 */
export interface BarChartProps<TValue extends ValueType, TName extends NameType>
  extends BaseChartProps<TValue, TName> {
  barCategoryGap?: number
  barRadius?: number
  barGap?: number
  barSize?: number
  barProps?: Partial<React.ComponentProps<typeof Bar>>

  chartProps?: Omit<ComponentProps<typeof BarChartPrimitive>, "data" | "stackOffset">
}

export function BarChart<TValue extends ValueType, TName extends NameType>({
  data = [],
  dataKey,
  colors = DEFAULT_COLORS,
  type = "default",
  config,
  children,
  layout = "horizontal",

  // Components
  tooltip = true,
  tooltipProps,

  legend = true,
  legendProps,

  intervalType = "equidistantPreserveStart",

  barCategoryGap = 5,
  barGap,
  barSize,
  barRadius,
  barProps,

  valueFormatter = (value: number) => value.toString(),

  // XAxis
  displayEdgeLabelsOnly = false,
  xAxisProps,
  hideXAxis = false,

  // YAxis
  yAxisProps,
  hideYAxis = false,

  hideGridLines = false,
  chartProps,

  ...props
}: BarChartProps<TValue, TName>) {
  const configKeys = useMemo(() => Object.keys(config), [config])
  const categoryColors = useMemo(
    () => constructCategoryColors(configKeys, colors),
    [configKeys, colors],
  )

  const configEntries = useMemo(() => Object.entries(config), [config])

  const stacked = type === "stacked" || type === "percent"
  const defaultBarRadius = stacked ? undefined : 4

  return (
    <Chart config={config} data={data} dataKey={dataKey} layout={layout} {...props}>
      {({ onLegendSelect, selectedLegend }) => (
        <BarChartPrimitive
          onClick={() => {
            onLegendSelect(null)
          }}
          data={data}
          margin={{
            bottom: 0,
            left: 5,
            right: 0,
            top: 5,
          }}
          layout={layout === "radial" ? "horizontal" : layout}
          barGap={barGap}
          barSize={barSize}
          barCategoryGap={barCategoryGap}
          stackOffset={type === "percent" ? "expand" : stacked ? "sign" : undefined}
          {...chartProps}
        >
          {!hideGridLines && <CartesianGrid strokeDasharray="4 4" />}
          <XAxis
            hide={hideXAxis}
            className="**:[text]:fill-quebi-fg-muted"
            displayEdgeLabelsOnly={displayEdgeLabelsOnly}
            intervalType={intervalType}
            {...xAxisProps}
          />
          <YAxis
            hide={hideYAxis}
            className="**:[text]:fill-quebi-fg-muted"
            tickFormatter={type === "percent" ? valueToPercent : valueFormatter}
            {...yAxisProps}
          />

          {legend && (
            <ChartLegend
              content={typeof legend === "boolean" ? <ChartLegendContent /> : legend}
              {...legendProps}
            />
          )}

          {tooltip && (
            <ChartTooltip
              content={
                typeof tooltip === "boolean" ? <ChartTooltipContent accessibilityLayer /> : tooltip
              }
              {...tooltipProps}
            />
          )}

          {!children
            ? configEntries.map(([category, values]) => {
                const color = getColorValue(values.color || categoryColors.get(category))
                const strokeOpacity = selectedLegend && selectedLegend !== category ? 0.2 : 0
                const fillOpacity = selectedLegend && selectedLegend !== category ? 0.1 : 1

                return (
                  <Bar
                    key={category}
                    name={category}
                    dataKey={category}
                    stroke={color}
                    strokeWidth={1}
                    stackId={stacked ? "stack" : undefined}
                    onClick={(_item, _number, event) => {
                      event.stopPropagation()

                      startTransition(() => {
                        onLegendSelect(category)
                      })
                    }}
                    radius={barRadius ?? defaultBarRadius}
                    strokeOpacity={strokeOpacity}
                    fillOpacity={fillOpacity}
                    fill={color}
                    {...barProps}
                  />
                )
              })
            : children}
        </BarChartPrimitive>
      )}
    </Chart>
  )
}