RankedBars
Preview
Basic
Top Items by Spend
Preview
Code
ts
import RankedBars from '@/components/data/RankedBars';src/components/data/RankedBars.tsx
AI prompt
text
Build a ranked horizontal bar chart card in React + TypeScript + Tailwind CSS, using Recharts (`BarChart` with `layout="vertical"`).
Horizontal bars suit a short ranked list of named categories: the labels stay readable on the y-axis instead of being rotated under vertical bars.
## Look
- A card (house panel) with `p-5`. Title "Top Items by Spend" as a section title (10px semibold uppercase wide-tracking slate-500 / dark slate-400), `mb-3`.
- The chart height grows with the rows: `max(220, rows.length * 40 + 24)` px, inside a ResponsiveContainer. Margins: top 4, right 56 (room for the value labels), left 8, bottom 4.
- Y axis: the category names, 120px wide, 12px ticks in the axis grey, no tick lines and no axis line. The X axis is hidden.
- Bars: `maxBarSize 26`, right end rounded 4px (`radius [0,4,4,0]`). Each bar is coloured by its ROI: emerald #10b981 when roi ≥ 0, red #ef4444 when negative.
- A value label to the right of each bar, 11px in the axis grey, as compact currency.
- Axis grey: #6b7280 light / #9ca3af dark. Tooltip box: white / #1f2937 background, 1px #e5e7eb / #374151 border, 8px radius, 12px text in #111827 / #f3f4f6. Hover cursor band: `rgba(0,0,0,0.04)` light / `rgba(255,255,255,0.04)` dark. Read dark mode from the `.dark` class on <html>.
## Behaviour
- Rows are drawn in the order given; the caller sorts them.
- Compact currency: `Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', notation: 'compact', maximumFractionDigits: 1 })`, so "$184.3K".
- The tooltip reads "Total Spend" with the value `$184.3K · 1204 FTD · ROI 31.4%`: spend, FTD count, ROI to one decimal.
- No rows: a 320px-tall centred message "No data for the selected period." in 12px slate-400 / dark slate-500.
## API
`rows: { id: number; name: string; brandName?: string; totalSpend: number; ftd: number; roi: number }[]`. Plots `totalSpend`.
## Demo
Five departments: Engineering $184,320 / 1,204 / 31.4%, Research $152,880 / 986 / 18.2%, Design $98,400 / 610 / −4.7% (red bar), Support $74,150 / 402 / 9.1%, Operations $41,900 / 233 / 22.8%.
## 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';
/* Origin: marketing-stats (96S1), verbatim. */
import { Bar, BarChart, Cell, LabelList, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
import type { RankedRow } from './types';
import { compactCurrency, useChartTheme } from './chartTheme';
// Horizontal bars ranked by Total Spend — the natural form for a short ranked
// list of named categories (labels stay readable on the y-axis instead of being
// rotated under vertical bars).
export default function RankedBars({ rows }: { rows: RankedRow[] }) {
const t = useChartTheme();
return (
<div className="panel p-5">
<h2 className="panel-title mb-3">Top Items by Spend</h2>
{rows.length === 0 ? (
<div className="flex h-[320px] items-center justify-center text-xs text-slate-400 dark:text-slate-500">No data for the selected period.</div>
) : (
<div style={{ height: Math.max(220, rows.length * 40 + 24) }}>
<ResponsiveContainer width="100%" height="100%">
<BarChart data={rows} layout="vertical" margin={{ top: 4, right: 56, left: 8, bottom: 4 }}>
<XAxis type="number" hide tickFormatter={compactCurrency} />
<YAxis
type="category"
dataKey="name"
width={120}
tick={{ fontSize: 12, fill: t.axis }}
tickLine={false}
axisLine={false}
/>
<Tooltip
contentStyle={t.tooltip}
cursor={{ fill: t.dark ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.04)' }}
formatter={(v, _n, item) => {
const row = item?.payload as RankedRow;
return [`${compactCurrency(Number(v))} · ${row.ftd} FTD · ROI ${row.roi.toFixed(1)}%`, 'Total Spend'];
}}
/>
<Bar dataKey="totalSpend" radius={[0, 4, 4, 0]} maxBarSize={26}>
{rows.map((b) => (
<Cell key={b.id} fill={b.roi >= 0 ? t.series.deposit : t.series.negative} />
))}
<LabelList
dataKey="totalSpend"
position="right"
formatter={(v: React.ReactNode) => compactCurrency(Number(v))}
style={{ fontSize: 11, fill: t.axis }}
/>
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
)}
</div>
);
}
Props
| Prop | Type | Default | Description |
|---|---|---|---|
rows* | RankedRow[] | — |