Stacked Bars
Installation
npx shadcn@latest add https://uibrik.com/r/stacked-bars.jsonInstall the packages
npm install @number-flow/react @tabler/icons-react motion react-use-measureAdd the cn helper
Imports
cnfrom@/lib/utils, whichshadcn initcreates. Add it by hand without shadcn.Copy the source into your project
components/uibrik/stacked-bars/stacked-bars.tsx"use client"; import { useEffect, useRef, useState, type PointerEvent } from "react"; import { animate, motion, useMotionValue, useReducedMotion, useTransform } from "motion/react"; import { cn } from "@/lib/utils"; import { Announce, ChartCard, DataTable, Delta, Headline, Legend, Segmented, Tooltip, TooltipRow, TooltipTitle, useChartCursor, useWidth, } from "../_charts/parts"; import { barPath, clamp, linear, niceTicks } from "../_charts/scale"; import { BAR_MAX, BAR_RADIUS, DRAW_IN, EASE_OUT, FOLLOW, GAP, GRID, SETTLE, seriesColor } from "../_charts/theme"; import { formatCompact, formatValue, type ValueFormat } from "../_charts/format"; import { monthLabels, walk } from "../_charts/sample"; /** One period: its label plus a number for every series key. */ export type StackedBarsRow = { label: string; [key: string]: string | number }; export type StackedBarsSeries = { key: string; label: string; color?: string }; export type StackedBarsMode = "stacked" | "grouped"; const SAMPLE_SERIES: StackedBarsSeries[] = [ { key: "starter", label: "Starter" }, { key: "pro", label: "Pro" }, { key: "enterprise", label: "Enterprise" }, ]; const SAMPLE_LABELS = monthLabels(12); const STARTER = walk(12, { seed: 11, start: 8200, trend: 0.004, noise: 0.07 }); const PRO = walk(12, { seed: 23, start: 13600, trend: 0.022, noise: 0.06 }); const ENTERPRISE = walk(12, { seed: 37, start: 7400, trend: 0.045, noise: 0.08 }); const SAMPLE: StackedBarsRow[] = SAMPLE_LABELS.map((label, i) => ({ label, starter: STARTER[i], pro: PRO[i], enterprise: ENTERPRISE[i], })); const MODES = [ { value: "stacked", label: "Stacked" }, { value: "grouped", label: "Grouped" }, ] as const; const HEIGHT = 220; // Room above the top grid line for its label, and below the baseline for months. const TOP = 8; const BOTTOM = 24; const BASE = HEIGHT - BOTTOM; // A month label is about this wide; closer columns show every other one. const LABEL_W = 30; // Share of a column slot the bars may use. The rest is air between months. const STACK_FILL = 0.6; const GROUP_FILL = 0.78; type Box = { x: number; y: number; w: number; h: number; r: number }; const num = (row: StackedBarsRow, key: string) => { const v = Number(row[key]); return Number.isFinite(v) && v > 0 ? v : 0; }; export type StackedBarsProps = { /** Names what is plotted. */ title?: string; /** One row per period, oldest first: { label, [series key]: value }. */ data?: StackedBarsRow[]; /** The series to draw, bottom of the stack first. */ series?: StackedBarsSeries[]; /** Stack each period into one column, or set the series side by side. Controlled. */ mode?: StackedBarsMode; /** Starting mode when uncontrolled. */ defaultMode?: StackedBarsMode; onModeChange?: (mode: StackedBarsMode) => void; /** Intl.NumberFormat options for every number, e.g. { style: "currency", currency: "USD" }. */ format?: ValueFormat; className?: string; }; export function StackedBars({ title = "Revenue by plan", data = SAMPLE, series = SAMPLE_SERIES, mode, defaultMode = "stacked", onModeChange, format = { style: "currency", currency: "USD", maximumFractionDigits: 0 }, className, }: StackedBarsProps) { const reduce = useReducedMotion() ?? false; const [ref, width] = useWidth(); const cursor = useChartCursor(data.length); const { active } = cursor; const [innerMode, setInnerMode] = useState<StackedBarsMode>(defaultMode); const current = mode ?? innerMode; const [hidden, setHidden] = useState<string[]>([]); const [focus, setFocus] = useState<string | null>(null); const setMode = (m: StackedBarsMode) => { setInnerMode(m); onModeChange?.(m); }; const toggle = (key: string) => setHidden((h) => (h.includes(key) ? h.filter((k) => k !== key) : [...h, key])); const colors = series.map((s, i) => s.color ?? seriesColor(i)); const visible = series.filter((s) => !hidden.includes(s.key)); const totals = data.map((row) => visible.reduce((sum, s) => sum + num(row, s.key), 0)); const periodTotal = totals.reduce((a, b) => a + b, 0); const last = totals[totals.length - 1] ?? 0; const prev = totals[totals.length - 2] ?? 0; const change = prev ? (last - prev) / prev : 0; const fmt = (v: number) => formatValue(v, format); // Stacked columns are as tall as their total; grouped bars only as their tallest series. const peak = Math.max( 0, ...data.map((row, i) => (current === "stacked" ? totals[i] : Math.max(0, ...visible.map((s) => num(row, s.key))))), ); const ticks = niceTicks(0, peak, 4); const top = ticks[ticks.length - 1]; // The gutter grows to fit the widest tick label, so nothing is clipped. const gutter = Math.ceil(Math.max(...ticks.map((t) => formatCompact(t, format).length)) * 6.5) + 10; const slot = width ? (width - gutter) / Math.max(1, data.length) : 0; const y = linear([0, top], [BASE, TOP]); const centre = (i: number) => gutter + slot * (i + 0.5); // Every segment's box in the current mode, per column then per series. const boxes = ((): Box[][] => { if (!slot) return []; const k = visible.length; if (current === "stacked") { const w = Math.min(BAR_MAX, slot * STACK_FILL); return data.map((row, i) => { const x = gutter + slot * (i + 0.5) - w / 2; // The highest series with a value wears the rounded top. const crown = series.findLastIndex((s) => !hidden.includes(s.key) && num(row, s.key) > 0); let below = 0; return series.map((s, j) => { const v = hidden.includes(s.key) ? 0 : num(row, s.key); const bottom = y(below) - (below > 0 && v > 0 ? GAP : 0); const top = y(below + v); below += v; return { x, y: Math.min(top, bottom), w, h: Math.max(0, bottom - top), r: j === crown ? BAR_RADIUS : 0 }; }); }); } const group = Math.min(slot * GROUP_FILL, Math.max(1, k) * BAR_MAX + Math.max(0, k - 1) * GAP); const w = k ? (group - (k - 1) * GAP) / k : 0; return data.map((row, i) => { const left = gutter + slot * (i + 0.5) - group / 2; let seat = 0; return series.map((s) => { const off = hidden.includes(s.key); // A hidden bar sinks where it would sit, so it rises back in place. const x = left + clamp(seat, 0, Math.max(0, k - 1)) * (w + GAP); if (off) return { x, y: BASE, w: w || BAR_MAX / 2, h: 0, r: BAR_RADIUS }; seat++; const v = num(row, s.key); return { x, y: y(v), w, h: BASE - y(v), r: BAR_RADIUS }; }); }); })(); const onPointerMove = (e: PointerEvent<HTMLDivElement>) => { if (!slot) return; const x = e.clientX - e.currentTarget.getBoundingClientRect().left; cursor.point(clamp(Math.floor((x - gutter) / slot), 0, data.length - 1)); }; // The tallest mark in a column, where the tooltip and focus ring sit. const columnTop = (i: number) => Math.min(BASE, ...(boxes[i] ?? []).filter((b) => b.h > 0).map((b) => b.y)); const columnHalf = (i: number) => { const b = boxes[i]?.filter((b) => b.w > 0) ?? []; if (!b.length) return 0; return (Math.max(...b.map((b) => b.x + b.w)) - Math.min(...b.map((b) => b.x))) / 2; }; const labelStep = slot ? Math.max(1, Math.ceil(LABEL_W / slot)) : 1; const row = active !== null ? data[active] : undefined; return ( <ChartCard title={title} value={<Headline value={active !== null ? totals[active] : periodTotal} format={format} />} meta={ row ? ( <span className="text-xs font-medium text-muted-foreground">{row.label}</span> ) : ( <Delta value={change} label={data.length > 1 ? `${data[data.length - 1].label} vs ${data[data.length - 2].label}` : undefined} /> ) } aside={<Segmented options={MODES} value={current} onChange={setMode} label="Layout" />} footer={ series.length > 1 && ( <Legend items={series.map((s, i) => ({ key: s.key, label: s.label, color: colors[i] }))} hidden={hidden} onToggle={toggle} active={focus} onActiveChange={setFocus} /> ) } className={cn("max-w-160", className)} > <div ref={ref} role="group" aria-label={`${title}, ${data.length} periods. Arrow keys move between them.`} className="relative touch-none rounded-lg outline-none focus-visible:ring-2 focus-visible:ring-foreground/20" style={{ height: HEIGHT }} onPointerMove={onPointerMove} onPointerDown={onPointerMove} {...cursor.bind} > {width > 0 && ( <svg width={width} height={HEIGHT} className="block overflow-visible" aria-hidden> {/* Grid and its labels glide to the new scale when series come and go. */} {ticks.map((t) => ( <motion.g key={t} initial={{ y: y(t), opacity: 0 }} animate={{ y: y(t), opacity: 1 }} transition={reduce ? { duration: 0 } : { ...SETTLE, opacity: { duration: 0.2 } }} > <line x1={gutter} x2={width} y1={0} y2={0} stroke={GRID} strokeWidth={1} shapeRendering="crispEdges" /> <text x={gutter - 8} y={0} dy="0.32em" textAnchor="end" className="fill-muted-foreground text-[11px] font-medium tabular-nums"> {formatCompact(t, format)} </text> </motion.g> ))} {boxes.map((column, i) => ( <g key={i} className="transition-opacity duration-150" style={{ opacity: active !== null && active !== i ? 0.4 : 1 }} > {column.map((box, j) => ( <Segment key={series[j].key} box={box} fill={colors[j]} delay={(i / Math.max(1, data.length)) * DRAW_IN * 0.5} dim={focus !== null && focus !== series[j].key} reduce={reduce} /> ))} </g> ))} {data.map((d, i) => (data.length - 1 - i) % labelStep === 0 ? ( <text key={i} x={centre(i)} y={HEIGHT - 6} textAnchor="middle" className={cn( "text-[11px] font-medium tabular-nums transition-colors duration-150", active === i ? "fill-foreground" : "fill-muted-foreground", )} > {d.label} </text> ) : null, )} {cursor.keyboard && active !== null && ( <motion.rect initial={false} animate={{ x: centre(active) - columnHalf(active) - 4, y: columnTop(active) - 4, width: columnHalf(active) * 2 + 8, height: BASE - columnTop(active) + 8, }} transition={reduce ? { duration: 0 } : FOLLOW} rx={8} fill="none" className="stroke-foreground/40" strokeWidth={2} /> )} </svg> )} <Tooltip x={active !== null ? centre(active) : 0} y={active !== null ? columnTop(active) - 4 : 0} bounds={width} visible={row !== undefined}> {row && ( <> <TooltipTitle>{row.label}</TooltipTitle> {/* Top of the stack first, so the rows read in the order the eye meets them. */} {series .map((s, j) => ({ s, j })) .filter(({ s }) => !hidden.includes(s.key)) .reverse() .map(({ s, j }) => ( <TooltipRow key={s.key} color={colors[j]} value={fmt(num(row, s.key))} label={s.label} /> ))} <div className="mt-1.5 border-t border-background/15 pt-0.5"> <TooltipRow color="transparent" value={fmt(totals[active!])} label="Total" /> </div> </> )} </Tooltip> </div> <Announce> {row ? `${row.label}: ${visible.map((s) => `${s.label} ${fmt(num(row, s.key))}`).join(", ")}, total ${fmt(totals[active!])}` : ""} </Announce> <DataTable caption={title} columns={["Period", ...series.map((s) => s.label), "Total"]} rows={data.map((d) => [d.label, ...series.map((s) => fmt(num(d, s.key))), fmt(series.reduce((t, s) => t + num(d, s.key), 0))])} /> </ChartCard> ); } /** * One bar segment. Its box tweens as a whole (position, size and corner), so * switching between stacked and grouped slides every piece to its new seat. */ function Segment({ box, fill, delay, dim, reduce }: { box: Box; fill: string; delay: number; dim: boolean; reduce: boolean }) { // Grows up from the baseline on mount. const x = useMotionValue(box.x); const y = useMotionValue(reduce ? box.y : BASE); const w = useMotionValue(box.w); const h = useMotionValue(reduce ? box.h : 0); const r = useMotionValue(box.r); const d = useTransform(() => barPath(x.get(), y.get(), w.get(), h.get(), r.get())); const mounted = useRef(false); useEffect(() => { const transition = reduce ? { duration: 0 } : mounted.current ? SETTLE : { duration: DRAW_IN, ease: EASE_OUT, delay }; mounted.current = true; const runs = [ animate(x, box.x, transition), animate(y, box.y, transition), animate(w, box.w, transition), animate(h, box.h, transition), animate(r, box.r, transition), ]; return () => runs.forEach((a) => a.stop()); // `delay` only matters for the first run. // eslint-disable-next-line react-hooks/exhaustive-deps }, [box.x, box.y, box.w, box.h, box.r, reduce, x, y, w, h, r]); return ( <g className="transition-opacity duration-150" style={{ opacity: dim ? 0.3 : 1 }}> <motion.path d={d} fill={fill} /> </g> ); }
Adds the files to
components/uibrik/stacked-bars and installs @number-flow/react, @tabler/icons-react, motion and react-use-measure. With the @uibrik namespace set up: npx shadcn@latest add @uibrik/stacked-bars.Props
StackedBars- title
- Type string
- "Revenue by plan"
- Names what is plotted.
- defaultMode
- Type "stacked" | "grouped"
- "stacked"
- Starting layout: one column per period, or series side by side.
- data
- Type { label: string; [key: string]: number }[]
- –
- One row per period, oldest first, with a value for each series key.
- series
- Type { key: string; label: string; color?: string }[]
- –
- The series to draw, bottom of the stack first.
- mode
- Type "stacked" | "grouped"
- –
- Controls the layout. Pair with onModeChange.
- onModeChange
- Type (mode) => void
- –
- Called when the layout tabs change.
- format
- Type Intl.NumberFormatOptions
- USD currency
- Formats every number, e.g. { style: "percent" }.
- className
- Type string
- –
- Classes for the outer shell.