v1.0

BarChart

Preview

Grouped

Loading…

Preview

Stacked

Loading…

Preview

Horizontal

Loading…

Preview

Code

ts
import BarChart from '@/components/data/BarChart';

src/components/data/BarChart.tsx

AI prompt

text
Build a bar chart component (grouped, stacked, or horizontal) in React + TypeScript + Tailwind CSS, drawn with Recharts (`BarChart`, `Bar`, `ResponsiveContainer`).

## Look
- Frame: a panel card (`p-5`) with a header holding the title as a 10px uppercase section title plus an ⓘ hint tooltip, a legend when there are 2+ series (10px `rounded-sm` swatch + 11px label), and at the right a two-icon chart / table toggle (lucide `BarChart3` / `Table2`). The table view lists the category plus one right-aligned, formatted column per series. No rows → a 240px "No data for the selected period." placeholder.
- Plot: default 280px tall, full width, margin top 4 / right 8 / left 0.
- Grid: solid hairlines only across the value axis (horizontal lines for columns, vertical for horizontal bars), #e2e8f0 / dark #334155. Ticks 11px #64748b / dark #94a3b8, no tick marks, axis line in the grid colour. The value axis has no axis line and is 48px wide; in horizontal mode the category axis is 96px wide.
- Bars are capped at 24px and never fill the band (`barCategoryGap` 28%, `barGap` 2) — the leftover is air.
- Data ends are rounded 4px and square at the baseline (columns `[4,4,0,0]`, horizontal `[0,4,4,0]`). Stacked: only the outermost (last) series is rounded, and touching segments are separated by a thin stroke in the card's own colour (#ffffff / dark #1e293b) rather than a drawn border.
- Series colours come from one fixed categorical order — light #2a78d6, #eb6834, #1baf7a, #eda100, #e87ba4, #008300, #4a3aa7, #e34948; dark #3987e5, #d95926, #199e70, #c98500, #d55181, #008300, #9085e9, #e66767 (choose the set from the active mode). A series uses its `slot` if given, else its declaration index, clamped to eight: pinning slots means filtering one series out never repaints the survivors. A ninth series is a design error — fold it into "Other".

## Behaviour
- Hover: the band is shaded `rgba(15,23,42,0.04)` (dark `rgba(255,255,255,0.04)`) and ONE tooltip lists every series in that category — an opaque floating card, category in 11px slate-500 on top, then per series a 14×3px colour stroke, the value in semibold tabular-nums, the series name in slate-500.
- Horizontal mode grows with the data: height = max(height, rows × 36 + 32).
- Animation off.
- Default value format: compact number (`Intl.NumberFormat`, `notation: 'compact'`, max 1 fraction digit → "42.6K"), used on the value axis, tooltip and table.

## API
`title`, `hint?`, `data: Record<string, string | number>[]`, `x: string` (category key; its table header is the key capitalised), `series: { key: string; label: string; slot?: number }[]`, `stacked = false`, `horizontal = false`, `format?: (v: number) => string`, `height = 280`, `className?`.

## Demo
"MRR by plan": twelve months Oct–Sep with Starter (~$18K–29K), Pro (~$42K–70K) and Enterprise (~$61K–115K) on slots 0–2, formatted as compact currency ("$64.2K"). Show it grouped, then stacked, then a horizontal "Open tickets by team" with Open / Overdue: Customer success 184 / 22, Billing 96 / 14, Integrations 71 / 5, Platform 58 / 9, Security 23 / 1.

## House style (applies to everything above)
- Stack: React 19 + TypeScript + Tailwind CSS v4, icons from lucide-react. One self-contained file; default-export the component and named-export its types. `'use client'` if it has state, refs or handlers.
- Font Inter; palette indigo on slate. Primary accent indigo-600 (hover indigo-700, dark mode indigo-400). Body text slate-700 / dark slate-200; secondary slate-500 / dark slate-400.
- Dark mode is a `.dark` class on <html> (not prefers-color-scheme). Every colour needs its `dark:` pair.
- Compact admin scale: text-xs (12px) for controls and body, 10–11px for meta, rounded-lg (8px) controls, rounded-2xl (16px) cards.
- Card surface ("panel"): `bg-white/60 dark:bg-slate-800/60 backdrop-blur-xl border border-white/60 dark:border-slate-700/60 rounded-2xl shadow-lg`, on a soft slate gradient page background.
- Floating surfaces (dropdowns, popovers, menus) are OPAQUE: `bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-2xl shadow-lg`, no backdrop blur (it creates a stacking context that traps the popover's z-index). In-flow popovers are z-50; portalled overlays z-200.
- Text inputs and select triggers: `w-full px-3 py-2 text-xs rounded-lg border border-slate-300 dark:border-slate-700 bg-white/80 dark:bg-slate-900/60 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-500/40 focus:border-indigo-500`.
- Field labels: 11px semibold slate-600. Section titles: 10px semibold uppercase wide-tracking slate-500.
- Primary button: indigo-600 fill, white 12px semibold text, rounded-lg, px-3 py-2, disabled at 50% opacity. Ghost button: slate-600 text, hover slate-100.
- Popovers close on outside click AND on Escape (listen to both; include the portalled panel's element in the outside-click check).
- Don't nest scroll containers around popovers: an ancestor with overflow hidden/auto clips an absolutely-positioned dropdown. Portal the panel to <body> when it must escape a scroller, and reposition it on scroll and resize.
- Accessible by default: visible focus rings, keyboard support that matches the WAI-ARIA pattern for the widget, `aria-label` on icon-only buttons, `min-w-0` so text truncates instead of overflowing.

Source

tsx
'use client';

import { Bar, BarChart as RBarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
import ChartCard, { ChartTooltip } from './ChartCard';
import { axisProps, seriesColor, type ChartSeries } from './chartSeries';
import { compactNumber, useChartPalette } from './chartTheme';

/**
 * Bars for comparing magnitude across categories — grouped, stacked, or
 * horizontal for long names.
 *
 * ── Which arrangement ───────────────────────────────────────────────────────
 *
 *   grouped   the series side by side: compare series WITHIN a category
 *   stacked   one bar per category, split by series: the total is the story,
 *             the parts are its make-up (part-to-whole)
 *   horizontal  for many categories or long names, which a column chart
 *             would have to rotate or truncate
 *
 * Bars are capped at 24px and never fill the band — the leftover is air.
 * Data-ends are 4px rounded and square at the baseline; a stack rounds only
 * its top segment, and a 2px gap in the card's own colour separates touching
 * segments instead of a drawn border.
 */
export default function BarChart({
  title,
  hint,
  data,
  x,
  series,
  stacked = false,
  horizontal = false,
  format = compactNumber,
  height = 280,
  className,
}: {
  title: string;
  hint?: string;
  data: Record<string, string | number>[];
  /** The category key. */
  x: string;
  series: ChartSeries[];
  stacked?: boolean;
  /** Bars left-to-right instead of columns bottom-up. */
  horizontal?: boolean;
  format?: (v: number) => string;
  height?: number;
  className?: string;
}) {
  const p = useChartPalette();
  const axis = axisProps(p);
  const last = series.length - 1;

  return (
    <ChartCard
      title={title}
      hint={hint}
      className={className}
      empty={data.length === 0}
      legend={series.map((s, i) => ({ label: s.label, color: seriesColor(p, s, i) }))}
      table={{
        columns: [{ key: x, label: x.charAt(0).toUpperCase() + x.slice(1) }, ...series.map((s) => ({ key: s.key, label: s.label, align: 'right' as const, format: (v: unknown) => format(Number(v)) }))],
        rows: data,
      }}
    >
      <div style={{ height: horizontal ? Math.max(height, data.length * 36 + 32) : height }}>
        <ResponsiveContainer width="100%" height="100%">
          <RBarChart
            data={data}
            layout={horizontal ? 'vertical' : 'horizontal'}
            margin={{ top: 4, right: 8, left: 0, bottom: 0 }}
            barGap={2}
            barCategoryGap="28%"
          >
            <CartesianGrid vertical={horizontal} horizontal={!horizontal} stroke={p.grid} />
            {horizontal ? (
              <>
                <XAxis type="number" tickFormatter={format} {...axis} />
                <YAxis type="category" dataKey={x} width={96} {...axis} axisLine={false} />
              </>
            ) : (
              <>
                <XAxis dataKey={x} {...axis} />
                <YAxis tickFormatter={format} width={48} {...axis} axisLine={false} />
              </>
            )}
            <Tooltip
              cursor={{ fill: p.dark ? 'rgba(255,255,255,0.04)' : 'rgba(15,23,42,0.04)' }}
              content={(props) => <ChartTooltip {...props} format={format} />}
            />
            {series.map((s, i) => {
              // Only the outermost segment of a stack gets the rounded end.
              const rounded = !stacked || i === last;
              const r = rounded ? 4 : 0;
              return (
                <Bar
                  key={s.key}
                  dataKey={s.key}
                  name={s.label}
                  fill={seriesColor(p, s, i)}
                  stackId={stacked ? 'stack' : undefined}
                  maxBarSize={24}
                  radius={horizontal ? [0, r, r, 0] : [r, r, 0, 0]}
                  // The 2px surface gap between touching stacked segments.
                  stroke={stacked ? p.surface : undefined}
                  strokeWidth={stacked ? 1 : 0}
                  isAnimationActive={false}
                />
              );
            })}
          </RBarChart>
        </ResponsiveContainer>
      </div>
    </ChartCard>
  );
}

Props

PropTypeDefaultDescription
title*string—
data*Record<string, string | number>[]—
x*string—The category key.
series*ChartSeries[]—
hintstring—
stackedbooleanfalse
horizontalbooleanfalseBars left-to-right instead of columns bottom-up.
format(v: number) => stringcompactNumber
heightnumber280
classNamestring—