v1.0

DivergingBars

Preview

Basic

Loading…

Preview

Code

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

src/components/data/DivergingBars.tsx

AI prompt

text
Build a diverging bar chart component in React + TypeScript + Tailwind CSS, drawn with Recharts (a vertical-layout `BarChart` with `Cell`, `LabelList` and `ReferenceLine`), for values above or below a baseline — change against target, gain against loss.

## Look
- Frame: a panel card (`p-5`) with a header holding the title as a 10px uppercase section title plus an ⓘ hint tooltip, a two-entry legend "Above" / "Below" (10px `rounded-sm` swatches + 11px labels), and at the right a two-icon chart / table toggle (lucide `BarChart3` / `Table2`).
- Horizontal bars, one per item, labels on the left (category axis 96px wide, no axis line). Height = max(200, items × 34 + 24)px. Right margin 48px so the value labels fit.
- Grid: vertical hairlines only, #e2e8f0 / dark #334155; ticks 11px #64748b / dark #94a3b8, no tick marks. A zero reference line in the tick colour.
- Two hues that read as opposites: positive (above) blue #2a78d6 / dark #3987e5, negative (below) red #e34948 / dark #e66767. Bars capped at 20px, `barCategoryGap` 30%.
- The data end is rounded 4px and the zero end square — so the rounded side flips with the sign.
- Each bar carries its signed value at the tip (11px, #475569 / dark #cbd5e1), so direction never rests on colour alone.

## Behaviour
- Items keep the caller's order; `sort` orders them largest first, turning the chart into a ranking.
- Hover: the row band is shaded `rgba(15,23,42,0.04)` (dark `rgba(255,255,255,0.04)`) and a tooltip shows an opaque floating card with the item name in 11px slate-500, then a 14×3px colour stroke, the value in semibold tabular-nums and `valueLabel` in slate-500.
- Table view columns: Item, and `valueLabel` (right-aligned, formatted). No items → the 240px "No data for the selected period." placeholder. Animation off.
- Default format: signed locale number — "+6.2", "-3.9", "0".

## API
`title`, `hint?`, `data: { label: string; value: number }[]`, `sort = false`, `format?: (v: number) => string`, `valueLabel = 'Change'`, `className?`.

## Demo
"CSAT against target" (points vs each team's target this quarter) in a `max-w-2xl` wrapper, `valueLabel="vs target"`, one decimal with sign: Integrations +6.2, Billing +3.1, Platform +0.8, Customer success −1.4, Onboarding −3.9, Security −5.5.

## 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, CartesianGrid, Cell, LabelList, ReferenceLine, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
import ChartCard, { ChartTooltip } from './ChartCard';
import { axisProps } from './chartSeries';
import { useChartPalette } from './chartTheme';

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

/**
 * Above or below a baseline — change against target, gain against loss.
 *
 * Two hues that read as OPPOSITES (blue for above, red for below) around a
 * zero line, so the sign reads before the size does. The value sits at each
 * bar's tip, signed, so the direction is never carried by colour alone.
 * Items keep the caller's order unless `sort` is set; a sorted list turns
 * the chart into a ranking, which is often the point.
 */
export default function DivergingBars({
  title,
  hint,
  data,
  sort = false,
  format = (v) => `${v > 0 ? '+' : ''}${v.toLocaleString()}`,
  valueLabel = 'Change',
  className,
}: {
  title: string;
  hint?: string;
  data: DivergingItem[];
  sort?: boolean;
  format?: (v: number) => string;
  /** What the value is, for the tooltip and the table. */
  valueLabel?: string;
  className?: string;
}) {
  const p = useChartPalette();
  const axis = axisProps(p);
  const rows = sort ? [...data].sort((a, b) => b.value - a.value) : data;

  return (
    <ChartCard
      title={title}
      hint={hint}
      className={className}
      empty={rows.length === 0}
      legend={[
        { label: 'Above', color: p.diverging.positive },
        { label: 'Below', color: p.diverging.negative },
      ]}
      table={{
        columns: [
          { key: 'label', label: 'Item' },
          { key: 'value', label: valueLabel, align: 'right', format: (v) => format(Number(v)) },
        ],
        rows,
      }}
    >
      <div style={{ height: Math.max(200, rows.length * 34 + 24) }}>
        <ResponsiveContainer width="100%" height="100%">
          <BarChart data={rows} layout="vertical" margin={{ top: 4, right: 48, left: 0, bottom: 0 }} barCategoryGap="30%">
            <CartesianGrid horizontal={false} stroke={p.grid} />
            <XAxis type="number" tickFormatter={format} {...axis} />
            <YAxis type="category" dataKey="label" width={96} {...axis} axisLine={false} />
            <ReferenceLine x={0} stroke={p.axis} />
            <Tooltip
              cursor={{ fill: p.dark ? 'rgba(255,255,255,0.04)' : 'rgba(15,23,42,0.04)' }}
              content={(props) => <ChartTooltip {...props} format={format} />}
            />
            <Bar dataKey="value" name={valueLabel} maxBarSize={20} isAnimationActive={false}>
              {rows.map((d) => (
                <Cell
                  key={d.label}
                  fill={d.value >= 0 ? p.diverging.positive : p.diverging.negative}
                  // Round the data-end, square at the zero line — which end
                  // that is depends on the sign.
                  radius={(d.value >= 0 ? [0, 4, 4, 0] : [4, 0, 0, 4]) as unknown as number}
                />
              ))}
              <LabelList dataKey="value" position="right" formatter={(v: unknown) => format(Number(v))} style={{ fontSize: 11, fill: p.inkSecondary }} />
            </Bar>
          </BarChart>
        </ResponsiveContainer>
      </div>
    </ChartCard>
  );
}

Props

PropTypeDefaultDescription
title*string—
data*DivergingItem[]—
hintstring—
sortbooleanfalse
format(v: number) => string(v) => `${v > 0 ? '+' : ''}${v.toLocaleString()}`
valueLabelstring'Change'What the value is, for the tooltip and the table.
classNamestring—