A vertical SVG bar chart with grouped and stacked modes, staggered rise-in, instant hover highlight with tooltip, and optional value labels.
npx shadcn@latest add @paragon/bar-chart"use client";
import * as React from "react";
import { useInView, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";
const SERIES_COLORS = [
"oklch(0.585 0.17 260)",
"oklch(0.68 0.13 165)",
"oklch(0.76 0.13 85)",
"oklch(0.62 0.18 305)",
"oklch(0.66 0.16 25)",
];
function niceTicks(max: number, count = 4): number[] {
if (max <= 0) return [0, 1];
const rough = max / count;
const magnitude = Math.pow(10, Math.floor(Math.log10(rough)));
const norm = rough / magnitude;
const step = (norm > 5 ? 10 : norm > 2 ? 5 : norm > 1 ? 2 : 1) * magnitude;
const top = Math.ceil(max / step) * step;
const ticks: number[] = [];
for (let v = 0; v <= top + step / 2; v += step) ticks.push(v);
return ticks;
}
export interface BarChartSeries {
name: string;
data: number[];
color?: string;
}
export interface BarChartProps extends React.ComponentProps<"div"> {
series: BarChartSeries[];
/** Category labels, one per column. */
labels: string[];
/** How multiple series share a column. */
mode?: "grouped" | "stacked";
height?: number;
/**
* Fraction of each band left empty as gap between columns, 0–0.9.
* 0.38 leaves a bar area of 62% of the band (the default rhythm).
*/
bandGap?: number;
/** Show horizontal gridlines behind the plot. */
showGrid?: boolean;
/** Value labels above each bar (grouped) or column total (stacked). */
showValues?: boolean;
showLegend?: boolean;
showYAxis?: boolean;
formatValue?: (value: number) => string;
/** Accessible description of the chart. */
label?: string;
/** Renders the final state immediately, no rise-in. */
static?: boolean;
}
/**
* A hand-built SVG vertical bar chart with grouped and stacked modes. Column
* bands, bar widths and gaps are derived from a band scale, and every bar
* shares the axis's zero baseline exactly. Columns scale up from the baseline
* with a stagger on first view; hovering highlights the column and shows a
* cursor-anchored tooltip. Columns are keyboard focusable and the legend
* toggles (click) / isolates (hover) series. Reduced motion renders the final
* state immediately.
*/
export function BarChart({
series,
labels,
mode = "grouped",
height = 240,
bandGap = 0.38,
showGrid = true,
showValues = false,
showLegend,
showYAxis = true,
formatValue = (v) => v.toLocaleString("en-US"),
label,
static: isStatic = false,
className,
...props
}: BarChartProps) {
const containerRef = React.useRef<HTMLDivElement>(null);
const [width, setWidth] = React.useState(0);
const reducedMotion = useReducedMotion();
const inView = useInView(containerRef, {
once: true,
margin: "0px 0px -48px 0px",
});
const [tip, setTip] = React.useState({ index: 0, x: 0, y: 0, visible: false });
const [hidden, setHidden] = React.useState<Set<number>>(new Set());
const [hovered, setHovered] = React.useState<number | null>(null);
React.useLayoutEffect(() => {
const el = containerRef.current;
if (!el) return;
const observer = new ResizeObserver(([entry]) =>
setWidth(entry.contentRect.width),
);
observer.observe(el);
return () => observer.disconnect();
}, []);
const isStacked = mode === "stacked" && series.length > 1;
const animate = !isStatic && !reducedMotion;
const drawn = !animate || inView;
const n = labels.length;
const hasData = series.length > 0 && n > 0;
const visible = series.map((_, i) => !hidden.has(i));
const visibleCount = Math.max(1, visible.filter(Boolean).length);
const pad = {
top: showValues ? 18 : 10,
right: 12,
bottom: 22,
left: showYAxis ? 44 : 8,
};
const innerW = Math.max(width - pad.left - pad.right, 0);
const plotBottom = height - pad.bottom;
const innerH = plotBottom - pad.top;
const columnTotals = labels.map((_, i) =>
series.reduce((sum, s, si) => sum + (visible[si] ? s.data[i] ?? 0 : 0), 0),
);
const dataMax = Math.max(
1,
...(isStacked
? columnTotals
: series.filter((_, i) => visible[i]).flatMap((s) => s.data)),
);
const ticks = niceTicks(dataMax);
const yMax = ticks[ticks.length - 1];
const band = n > 0 ? innerW / n : 0;
const barArea = band * (1 - Math.min(0.9, Math.max(0, bandGap)));
const groupGap = Math.min(4, barArea * 0.06);
const subWidth = isStacked
? barArea
: (barArea - (visibleCount - 1) * groupGap) / visibleCount;
const xBand = (i: number) => pad.left + i * band + (band - barArea) / 2;
const yFor = (v: number) => plotBottom - (v / yMax) * innerH;
const colorFor = (i: number) =>
series[i]?.color ?? SERIES_COLORS[i % SERIES_COLORS.length];
const setIndex = React.useCallback(
(index: number) => {
const i = Math.min(n - 1, Math.max(0, index));
setTip({ index: i, x: pad.left + i * band + band / 2, y: pad.top, visible: true });
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[n, band, pad.left],
);
const handleMove = (event: React.PointerEvent<SVGSVGElement>) => {
const rect = containerRef.current?.getBoundingClientRect();
if (!rect || band <= 0) return;
const px = event.clientX - rect.left;
const py = event.clientY - rect.top;
const index = Math.min(
n - 1,
Math.max(0, Math.floor((px - pad.left) / band)),
);
setTip({ index, x: px, y: py, visible: true });
};
const toggle = (i: number) =>
setHidden((prev) => {
const next = new Set(prev);
if (next.has(i)) next.delete(i);
else if (next.size < series.length - 1) next.add(i);
return next;
});
const flip = width > 0 && tip.x > width * 0.6;
const legendVisible = showLegend ?? series.length > 1;
return (
<div
ref={containerRef}
data-slot="bar-chart"
className={cn("relative w-full", className)}
{...props}
>
{!hasData ? (
<div
style={{ height }}
className="flex items-center justify-center rounded-lg border border-dashed text-sm text-muted-foreground"
>
No data
</div>
) : width > 0 ? (
<svg
role="img"
aria-label={
label ?? `Bar chart of ${series.map((s) => s.name).join(", ")}`
}
width={width}
height={height}
viewBox={`0 0 ${width} ${height}`}
preserveAspectRatio="xMidYMid meet"
className="block touch-none overflow-visible"
onPointerMove={handleMove}
onPointerLeave={() => setTip((t) => ({ ...t, visible: false }))}
>
{showGrid &&
ticks.map((tick) => (
<line
key={tick}
x1={pad.left}
x2={width - pad.right}
y1={yFor(tick)}
y2={yFor(tick)}
stroke="currentColor"
strokeOpacity={tick === 0 ? 0.16 : 0.07}
vectorEffect="non-scaling-stroke"
shapeRendering="crispEdges"
/>
))}
{showYAxis &&
ticks.map((tick) => (
<text
key={tick}
x={pad.left - 8}
y={yFor(tick)}
textAnchor="end"
dominantBaseline="central"
fontSize={10}
className="fill-muted-foreground tabular-nums"
>
{formatValue(tick)}
</text>
))}
{labels.map((text, i) => (
<text
key={i}
x={pad.left + i * band + band / 2}
y={height - 6}
textAnchor="middle"
fontSize={10}
className="fill-muted-foreground"
>
{text}
</text>
))}
{/* Hovered column band — instant, no transition */}
{tip.visible && (
<rect
x={pad.left + tip.index * band}
y={pad.top}
width={band}
height={innerH}
fill="currentColor"
fillOpacity={0.05}
aria-hidden
/>
)}
{/* Columns rise from the baseline with a stagger */}
{labels.map((_, i) => {
const hoveredCol = tip.visible && tip.index === i;
let stackedY = plotBottom;
let slot = 0;
return (
<g
key={i}
style={{
transform: drawn ? "scaleY(1)" : "scaleY(0)",
transformOrigin: `0px ${plotBottom}px`,
transition: animate
? `transform calc(400ms * var(--duration-scale, 1)) var(--ease-out) calc(${
i * 20
}ms * var(--duration-scale, 1))`
: undefined,
}}
>
{series.map((s, si) => {
if (!visible[si]) return null;
const v = s.data[i] ?? 0;
const barH = (v / yMax) * innerH;
let x: number;
let y: number;
if (isStacked) {
x = xBand(i);
stackedY -= barH;
y = stackedY;
} else {
x = xBand(i) + slot * (subWidth + groupGap);
slot += 1;
y = yFor(v);
}
const dimmed = hovered !== null && hovered !== si;
return (
<rect
key={s.name}
x={x}
y={y}
width={Math.max(subWidth, 1)}
height={Math.max(barH, 0)}
rx={isStacked ? 0 : Math.min(3, subWidth / 2)}
fill={colorFor(si)}
fillOpacity={hoveredCol ? 1 : dimmed ? 0.25 : 0.85}
style={{ transition: "fill-opacity 150ms var(--ease-out)" }}
/>
);
})}
</g>
);
})}
{/* Value labels fade in after the rise */}
{showValues &&
labels.map((_, i) => {
let slot = 0;
const items = isStacked
? [{ x: xBand(i) + barArea / 2, v: columnTotals[i], top: columnTotals[i] }]
: series
.map((s, si) => {
if (!visible[si]) return null;
const item = {
x: xBand(i) + slot * (subWidth + groupGap) + subWidth / 2,
v: s.data[i] ?? 0,
top: s.data[i] ?? 0,
};
slot += 1;
return item;
})
.filter((x): x is { x: number; v: number; top: number } => x !== null);
return items.map(({ x, v, top }, k) => (
<text
key={`${i}-${k}`}
x={x}
y={yFor(top) - 5}
textAnchor="middle"
fontSize={9.5}
className="fill-muted-foreground tabular-nums"
style={{
opacity: drawn ? 1 : 0,
transition: animate
? `opacity calc(250ms * var(--duration-scale, 1)) var(--ease-out) calc(${
300 + i * 20
}ms * var(--duration-scale, 1))`
: undefined,
}}
>
{formatValue(v)}
</text>
));
})}
{/* Keyboard-focusable column hit targets */}
{band > 0 &&
labels.map((lbl, i) => (
<rect
key={i}
x={pad.left + i * band}
y={pad.top}
width={band}
height={innerH}
fill="transparent"
tabIndex={0}
role="button"
aria-label={`${lbl}: ${series
.filter((_, si) => visible[si])
.map((s) => `${s.name} ${formatValue(s.data[i] ?? 0)}`)
.join(", ")}${
isStacked ? `, total ${formatValue(columnTotals[i])}` : ""
}`}
className="cursor-pointer outline-none [&:focus-visible]:stroke-ring"
style={{ strokeWidth: 2 }}
onFocus={() => setIndex(i)}
onBlur={() => setTip((t) => ({ ...t, visible: false }))}
onKeyDown={(e) => {
if (e.key === "ArrowRight" || e.key === "ArrowUp") {
e.preventDefault();
(
e.currentTarget.parentElement?.querySelectorAll(
"rect[role='button']",
)[Math.min(n - 1, i + 1)] as SVGRectElement | undefined
)?.focus();
} else if (e.key === "ArrowLeft" || e.key === "ArrowDown") {
e.preventDefault();
(
e.currentTarget.parentElement?.querySelectorAll(
"rect[role='button']",
)[Math.max(0, i - 1)] as SVGRectElement | undefined
)?.focus();
}
}}
/>
))}
</svg>
) : (
<div style={{ height }} />
)}
{/* Tooltip — instant movement, 100ms fade on appear */}
{hasData && (
<div
aria-hidden
className="pointer-events-none absolute top-0 left-0 z-10 min-w-32 rounded-lg bg-popover px-2.5 py-2 text-xs shadow-overlay transition-opacity duration-100"
style={{
opacity: tip.visible ? 1 : 0,
transform: `translate(${tip.x}px, ${tip.y}px) translate(${
flip ? "calc(-100% - 14px)" : "14px"
}, 10px)`,
}}
>
<div className="text-muted-foreground">{labels[tip.index]}</div>
<div className="mt-1.5 flex flex-col gap-1">
{series.map((s, si) =>
visible[si] ? (
<div key={s.name} className="flex items-center gap-1.5">
<span
className="size-2 shrink-0 rounded-full"
style={{ background: colorFor(si) }}
/>
<span className="text-muted-foreground">{s.name}</span>
<span className="ml-auto pl-3 font-medium tabular-nums">
{formatValue(s.data[tip.index] ?? 0)}
</span>
</div>
) : null,
)}
{isStacked && visible.filter(Boolean).length > 1 && (
<div className="mt-0.5 flex items-center gap-1.5 border-t pt-1">
<span className="text-muted-foreground">Total</span>
<span className="ml-auto pl-3 font-medium tabular-nums">
{formatValue(columnTotals[tip.index] ?? 0)}
</span>
</div>
)}
</div>
</div>
)}
{hasData && legendVisible && (
<div className="mt-3 flex flex-wrap items-center gap-x-1 gap-y-1">
{series.map((s, si) => (
<button
key={s.name}
type="button"
onClick={() => toggle(si)}
onPointerEnter={() => setHovered(si)}
onPointerLeave={() => setHovered(null)}
aria-pressed={visible[si]}
className="pressable inline-flex items-center gap-1.5 rounded-md px-1.5 py-1 text-xs text-muted-foreground outline-none transition-[color,opacity] duration-150 ease-out hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
style={{ opacity: visible[si] ? 1 : 0.4 }}
>
<span
className="size-2 shrink-0 rounded-[3px] transition-transform duration-150 ease-out"
style={{
background: colorFor(si),
transform: visible[si] ? "scale(1)" : "scale(0.6)",
}}
/>
<span className={cn(!visible[si] && "line-through")}>
{s.name}
</span>
</button>
))}
</div>
)}
</div>
);
}