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
| Prop | Type | Default | Description |
|---|---|---|---|
title* | string | — | |
data* | DivergingItem[] | — | |
hint | string | — | |
sort | boolean | false | |
format | (v: number) => string | (v) => `${v > 0 ? '+' : ''}${v.toLocaleString()}` | |
valueLabel | string | 'Change' | What the value is, for the tooltip and the table. |
className | string | — |