Chart Bar
Original · freehand-built SVG/div bar chart with axes
byWeb InnoventixReact + Tailwind
chartbarcharts
Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.
npx shadcn@latest add https://webinnoventix.com/r/chart-bar.jsonchart-bar.tsx
"use client";
import { useId, useMemo, useRef, useState } from "react";
import type {
FocusEvent as ReactFocusEvent,
KeyboardEvent as ReactKeyboardEvent,
} from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
type Datum = { category: string; short: string; value: number };
type Series = { id: string; label: string; projected?: boolean; data: Datum[] };
const SERIES: Series[] = [
{
id: "2023",
label: "2023",
data: [
{ category: "Solar PV", short: "Solar", value: 375 },
{ category: "Onshore wind", short: "Onshore", value: 106 },
{ category: "Offshore wind", short: "Offshore", value: 11 },
{ category: "Hydropower", short: "Hydro", value: 20 },
{ category: "Battery storage", short: "Battery", value: 95 },
{ category: "Geothermal", short: "Geo", value: 4 },
],
},
{
id: "2024",
label: "2024",
data: [
{ category: "Solar PV", short: "Solar", value: 452 },
{ category: "Onshore wind", short: "Onshore", value: 118 },
{ category: "Offshore wind", short: "Offshore", value: 15 },
{ category: "Hydropower", short: "Hydro", value: 18 },
{ category: "Battery storage", short: "Battery", value: 148 },
{ category: "Geothermal", short: "Geo", value: 5 },
],
},
{
id: "2025",
label: "2025",
projected: true,
data: [
{ category: "Solar PV", short: "Solar", value: 533 },
{ category: "Onshore wind", short: "Onshore", value: 130 },
{ category: "Offshore wind", short: "Offshore", value: 22 },
{ category: "Hydropower", short: "Hydro", value: 19 },
{ category: "Battery storage", short: "Battery", value: 210 },
{ category: "Geothermal", short: "Geo", value: 6 },
],
},
];
function niceScale(max: number, targetTicks = 5): { niceMax: number; step: number } {
const rawStep = max / targetTicks;
const exp = Math.floor(Math.log10(rawStep));
const base = Math.pow(10, exp);
const f = rawStep / base;
const niceF = f < 1.5 ? 1 : f < 3 ? 2 : f < 7 ? 5 : 10;
const step = niceF * base;
const niceMax = Math.ceil(max / step) * step;
return { niceMax, step };
}
const PLOT_H = "h-[280px] sm:h-[340px]";
export default function ChartBar() {
const reduce = !!useReducedMotion();
const uid = useId();
const [activeSeries, setActiveSeries] = useState(2);
const [sortBySize, setSortBySize] = useState(false);
const [roving, setRoving] = useState(0);
const [hoverIndex, setHoverIndex] = useState<number | null>(null);
const [focusWithin, setFocusWithin] = useState(false);
const containerRef = useRef<HTMLDivElement | null>(null);
const barRefs = useRef<Array<HTMLButtonElement | null>>([]);
const { niceMax, step } = useMemo(() => {
const globalMax = Math.max(
...SERIES.flatMap((s) => s.data.map((d) => d.value)),
);
return niceScale(globalMax, 5);
}, []);
const ticks = useMemo(() => {
const out: number[] = [];
for (let t = 0; t <= niceMax; t += step) out.push(t);
return out;
}, [niceMax, step]);
const active = SERIES[activeSeries];
const prev = activeSeries > 0 ? SERIES[activeSeries - 1] : null;
const rows = useMemo(() => {
const prevMap = new Map(prev?.data.map((d) => [d.category, d.value]) ?? []);
const total = active.data.reduce((sum, d) => sum + d.value, 0);
const built = active.data.map((d) => {
const prevVal = prevMap.get(d.category);
const yoy =
prevVal && prevVal > 0
? Math.round(((d.value - prevVal) / prevVal) * 100)
: null;
return {
...d,
share: Math.round((d.value / total) * 100),
yoy,
};
});
return sortBySize ? [...built].sort((a, b) => b.value - a.value) : built;
}, [active, prev, sortBySize]);
const total = useMemo(
() => active.data.reduce((sum, d) => sum + d.value, 0),
[active],
);
const peak = useMemo(
() => active.data.reduce((a, b) => (b.value > a.value ? b : a)),
[active],
);
const n = rows.length;
const activeIndex =
hoverIndex !== null ? hoverIndex : focusWithin ? roving : null;
const moveFocus = (next: number) => {
const idx = (next + n) % n;
setRoving(idx);
barRefs.current[idx]?.focus();
};
const onKeyDown = (e: ReactKeyboardEvent<HTMLDivElement>) => {
switch (e.key) {
case "ArrowRight":
case "ArrowDown":
e.preventDefault();
moveFocus(roving + 1);
break;
case "ArrowLeft":
case "ArrowUp":
e.preventDefault();
moveFocus(roving - 1);
break;
case "Home":
e.preventDefault();
moveFocus(0);
break;
case "End":
e.preventDefault();
moveFocus(n - 1);
break;
default:
break;
}
};
const onGroupFocus = (e: ReactFocusEvent<HTMLDivElement>) => {
const idx = (e.target as HTMLElement).dataset.index;
if (idx !== undefined) setRoving(Number(idx));
setFocusWithin(true);
};
const onGroupBlur = (e: ReactFocusEvent<HTMLDivElement>) => {
if (!containerRef.current?.contains(e.relatedTarget as Node)) {
setFocusWithin(false);
}
};
const ctrlRing =
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-emerald-500 dark:focus-visible:ring-emerald-400 focus-visible:ring-offset-white dark:focus-visible:ring-offset-zinc-900";
return (
<section className="relative w-full bg-zinc-50 px-4 py-16 text-zinc-900 dark:bg-zinc-950 dark:text-zinc-50 sm:py-24">
<style>{`
@keyframes cbz-fade-up { from { opacity: 0; transform: translateY(12px); } to { opacity: 1; transform: none; } }
@keyframes cbz-pulse { 0%, 100% { opacity: .5; transform: scale(1); } 50% { opacity: 1; transform: scale(1.4); } }
.cbz-fade { opacity: 0; animation: cbz-fade-up .6s cubic-bezier(.22,1,.36,1) forwards; }
.cbz-peak-dot { animation: cbz-pulse 2.4s ease-in-out infinite; }
@media (prefers-reduced-motion: reduce) {
.cbz-fade { animation: none !important; opacity: 1 !important; transform: none !important; }
.cbz-peak-dot { animation: none !important; }
}
`}</style>
<div className="mx-auto max-w-5xl">
<div className="overflow-hidden rounded-3xl border border-zinc-200 bg-white shadow-[0_1px_0_rgba(0,0,0,0.03),0_20px_50px_-30px_rgba(0,0,0,0.25)] dark:border-zinc-800 dark:bg-zinc-900">
{/* Header */}
<div className="flex flex-col gap-6 border-b border-zinc-200 p-6 dark:border-zinc-800 sm:flex-row sm:items-end sm:justify-between sm:p-8">
<div>
<p
className="cbz-fade text-[11px] font-semibold uppercase tracking-[0.2em] text-emerald-600 dark:text-emerald-400"
style={{ animationDelay: "40ms" }}
>
Energy transition monitor
</p>
<h2
className="cbz-fade mt-2 text-2xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-50 sm:text-3xl"
style={{ animationDelay: "110ms" }}
>
Clean-energy capacity added
</h2>
<p
className="cbz-fade mt-2 max-w-md text-sm leading-relaxed text-zinc-500 dark:text-zinc-400"
style={{ animationDelay: "180ms" }}
>
Net additions to global installed generation and storage
capacity, broken out by technology.
</p>
</div>
<div
className="cbz-fade flex items-stretch gap-5"
style={{ animationDelay: "240ms" }}
>
<div>
<div className="flex items-baseline gap-1.5">
<span className="font-mono text-3xl font-semibold tabular-nums tracking-tight text-zinc-900 dark:text-zinc-50">
{total.toLocaleString("en-US")}
</span>
<span className="text-sm font-medium text-zinc-400 dark:text-zinc-500">
GW
</span>
</div>
<p className="mt-1 text-xs text-zinc-500 dark:text-zinc-400">
Total added in {active.label}
{active.projected ? " (est.)" : ""}
</p>
</div>
<div className="w-px shrink-0 bg-zinc-200 dark:bg-zinc-800" />
<div className="self-center">
<p className="text-xs text-zinc-500 dark:text-zinc-400">
Led by
</p>
<p className="text-sm font-semibold text-emerald-600 dark:text-emerald-400">
{peak.category}
</p>
</div>
</div>
</div>
{/* Controls */}
<div className="flex flex-col gap-4 px-6 pt-6 dark:border-zinc-800 sm:flex-row sm:items-center sm:justify-between sm:px-8">
<div
role="group"
aria-label="Select reporting year"
className="inline-flex rounded-xl border border-zinc-200 bg-zinc-100/70 p-1 dark:border-zinc-800 dark:bg-zinc-800/50"
>
{SERIES.map((s, i) => {
const selected = i === activeSeries;
return (
<button
key={s.id}
type="button"
aria-pressed={selected}
onClick={() => setActiveSeries(i)}
className={[
"rounded-lg px-4 py-1.5 text-sm font-medium transition-colors",
ctrlRing,
selected
? "bg-white text-zinc-900 shadow-sm dark:bg-zinc-950 dark:text-zinc-50"
: "text-zinc-500 hover:text-zinc-800 dark:text-zinc-400 dark:hover:text-zinc-200",
].join(" ")}
>
{s.label}
</button>
);
})}
</div>
<button
type="button"
aria-pressed={sortBySize}
onClick={() => setSortBySize((v) => !v)}
className={[
"inline-flex items-center gap-2 rounded-xl border px-3.5 py-2 text-sm font-medium transition-colors",
ctrlRing,
sortBySize
? "border-emerald-300 bg-emerald-50 text-emerald-700 dark:border-emerald-500/40 dark:bg-emerald-500/10 dark:text-emerald-300"
: "border-zinc-200 bg-white text-zinc-600 hover:bg-zinc-50 dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-300 dark:hover:bg-zinc-800/60",
].join(" ")}
>
<svg
viewBox="0 0 24 24"
className="h-4 w-4"
fill="none"
stroke="currentColor"
strokeWidth={1.8}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M4 18h5M4 12h9M4 6h13" />
<path d="M17 15l3 3 3-3M20 18V8" />
</svg>
Sort by size
</button>
</div>
{/* Chart */}
<div className="p-6 sm:p-8">
<div className="flex gap-3 sm:gap-4">
{/* Y axis */}
<div className="shrink-0">
<div className="mb-2 text-right text-[10px] font-semibold uppercase tracking-widest text-zinc-400 dark:text-zinc-500">
GW
</div>
<div className={`relative w-9 ${PLOT_H}`}>
{ticks.map((t) => (
<span
key={t}
className="absolute right-0 -translate-y-1/2 font-mono text-[11px] tabular-nums text-zinc-400 dark:text-zinc-500"
style={{ top: `${100 - (t / niceMax) * 100}%` }}
>
{t}
</span>
))}
</div>
</div>
{/* Plot + X axis */}
<div className="min-w-0 flex-1">
<div className={`relative ${PLOT_H}`}>
{/* Gridlines + axes */}
<svg
className="absolute inset-0 h-full w-full"
viewBox="0 0 100 100"
preserveAspectRatio="none"
aria-hidden="true"
>
{ticks.map((t) => {
const y = 100 - (t / niceMax) * 100;
const isBase = t === 0;
return (
<line
key={t}
x1={0}
x2={100}
y1={y}
y2={y}
strokeWidth={1}
vectorEffect="non-scaling-stroke"
strokeDasharray={isBase ? undefined : "3 4"}
className={
isBase
? "stroke-zinc-300 dark:stroke-zinc-700"
: "stroke-zinc-200 dark:stroke-zinc-800"
}
/>
);
})}
<line
x1={0}
x2={0}
y1={0}
y2={100}
strokeWidth={1}
vectorEffect="non-scaling-stroke"
className="stroke-zinc-300 dark:stroke-zinc-700"
/>
</svg>
{/* Bars */}
<div
ref={containerRef}
role="group"
aria-label={`Capacity added by technology in ${active.label}. Use arrow keys to move between bars.`}
onKeyDown={onKeyDown}
onFocus={onGroupFocus}
onBlur={onGroupBlur}
className="absolute inset-0 flex items-end gap-2 px-1 sm:gap-3"
>
{rows.map((row, i) => {
const pct = (row.value / niceMax) * 100;
const isActive = activeIndex === i;
const isPeak = row.category === peak.category;
return (
<button
key={row.category}
type="button"
ref={(el) => {
barRefs.current[i] = el;
}}
data-index={i}
tabIndex={roving === i ? 0 : -1}
onMouseEnter={() => setHoverIndex(i)}
onMouseLeave={() => setHoverIndex(null)}
onClick={() => setRoving(i)}
aria-label={`${row.category}: ${row.value} gigawatts, ${row.share} percent of ${active.label} additions${
row.yoy !== null
? `, ${row.yoy >= 0 ? "up" : "down"} ${Math.abs(
row.yoy,
)} percent versus ${prev?.label}`
: ""
}`}
className="group relative flex h-full flex-1 items-end justify-center rounded-md outline-none transition-colors hover:bg-zinc-500/[0.04] focus-visible:bg-emerald-500/[0.06] dark:hover:bg-zinc-100/[0.04]"
>
{/* Static value label */}
<span
className={[
"pointer-events-none absolute left-1/2 -translate-x-1/2 font-mono text-[10px] tabular-nums text-zinc-500 transition-opacity dark:text-zinc-400",
isActive ? "opacity-0" : "opacity-100",
].join(" ")}
style={{ bottom: `calc(${pct}% + 5px)` }}
>
{row.value}
</span>
{/* Bar fill */}
<motion.div
initial={reduce ? false : { height: "0%" }}
animate={{ height: `${pct}%` }}
transition={
reduce
? { duration: 0 }
: {
type: "spring",
stiffness: 130,
damping: 20,
delay: i * 0.05,
}
}
className={[
"relative w-full max-w-[3rem] rounded-t-lg bg-gradient-to-t shadow-sm ring-offset-white transition-[filter,box-shadow] dark:ring-offset-zinc-900",
isActive
? "from-emerald-600 to-sky-400 dark:from-emerald-500 dark:to-sky-300"
: "from-emerald-600 to-emerald-400 dark:from-emerald-600 dark:to-emerald-400",
"group-focus-visible:ring-2 group-focus-visible:ring-offset-2 group-focus-visible:ring-sky-500 dark:group-focus-visible:ring-sky-400",
].join(" ")}
>
<span className="absolute inset-x-0 top-0 h-px rounded-t-lg bg-white/40" />
{isPeak && (
<span
aria-hidden="true"
className="cbz-peak-dot absolute -top-2 left-1/2 h-1.5 w-1.5 -translate-x-1/2 rounded-full bg-amber-400 shadow-[0_0_0_3px_rgba(251,191,36,0.25)]"
/>
)}
</motion.div>
</button>
);
})}
</div>
{/* Tooltip */}
<AnimatePresence>
{activeIndex !== null &&
(() => {
const row = rows[activeIndex];
const pct = (row.value / niceMax) * 100;
return (
<motion.div
key={`${active.id}-${row.category}`}
role="status"
initial={
reduce ? { opacity: 0 } : { opacity: 0, y: 6 }
}
animate={{ opacity: 1, y: 0 }}
exit={reduce ? { opacity: 0 } : { opacity: 0, y: 6 }}
transition={{ duration: reduce ? 0 : 0.16 }}
className="pointer-events-none absolute z-20 -translate-x-1/2 -translate-y-full"
style={{
left: `${((activeIndex + 0.5) / n) * 100}%`,
bottom: `calc(${Math.min(pct, 100)}% + 12px)`,
}}
>
<div className="w-max rounded-xl border border-zinc-200 bg-white/95 px-3 py-2 shadow-xl backdrop-blur dark:border-zinc-700 dark:bg-zinc-800/95">
<p className="text-xs font-semibold text-zinc-900 dark:text-zinc-50">
{row.category}
</p>
<p className="mt-0.5 font-mono text-sm font-semibold tabular-nums text-emerald-600 dark:text-emerald-400">
{row.value} GW
</p>
<div className="mt-1 flex items-center gap-2 text-[11px] text-zinc-500 dark:text-zinc-400">
<span>{row.share}% of total</span>
{row.yoy !== null && (
<span
className={
row.yoy >= 0
? "font-medium text-emerald-600 dark:text-emerald-400"
: "font-medium text-rose-500 dark:text-rose-400"
}
>
{row.yoy >= 0 ? "▲" : "▼"}{" "}
{Math.abs(row.yoy)}% vs {prev?.label}
</span>
)}
</div>
</div>
<span className="absolute left-1/2 top-full h-2 w-2 -translate-x-1/2 -translate-y-1 rotate-45 border-b border-r border-zinc-200 bg-white/95 dark:border-zinc-700 dark:bg-zinc-800/95" />
</motion.div>
);
})()}
</AnimatePresence>
</div>
{/* X axis */}
<div className="mt-3 flex gap-2 px-1 sm:gap-3">
{rows.map((row, i) => (
<div
key={row.category}
className={[
"min-w-0 flex-1 text-center text-[11px] font-medium transition-colors",
activeIndex === i
? "text-zinc-900 dark:text-zinc-100"
: "text-zinc-500 dark:text-zinc-400",
].join(" ")}
>
{row.short}
</div>
))}
</div>
</div>
</div>
{/* Footer */}
<div className="mt-6 flex flex-col gap-2 border-t border-zinc-200 pt-4 dark:border-zinc-800 sm:flex-row sm:items-center sm:justify-between">
<p className="text-[11px] text-zinc-400 dark:text-zinc-500">
Figures in gigawatts (GW). 2025 values are projected estimates.
</p>
<p
className="text-[11px] text-zinc-400 dark:text-zinc-500"
id={`${uid}-hint`}
>
Tip: hover a bar, or focus the chart and use{" "}
<kbd className="rounded border border-zinc-300 bg-zinc-100 px-1 font-mono text-[10px] text-zinc-600 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-300">
←
</kbd>{" "}
<kbd className="rounded border border-zinc-300 bg-zinc-100 px-1 font-mono text-[10px] text-zinc-600 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-300">
→
</kbd>{" "}
to compare.
</p>
</div>
</div>
</div>
</div>
{/* Screen-reader accessible data table */}
<table className="sr-only">
<caption>
Clean-energy capacity added by technology in {active.label}, in
gigawatts.
</caption>
<thead>
<tr>
<th scope="col">Technology</th>
<th scope="col">Capacity added (GW)</th>
<th scope="col">Share of total</th>
{prev && <th scope="col">Change vs {prev.label}</th>}
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.category}>
<th scope="row">{row.category}</th>
<td>{row.value}</td>
<td>{row.share}%</td>
{prev && (
<td>{row.yoy === null ? "—" : `${row.yoy >= 0 ? "+" : ""}${row.yoy}%`}</td>
)}
</tr>
))}
</tbody>
</table>
</section>
);
}Dependencies
motion
Licence
Built by Web Innoventix. Free for personal and commercial use, no attribution required.
Built by Web Innoventix
Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.
Get a free quoteSimilar components
Browse all →
Chart Bar Grouped
Originalgrouped bar chart with legend

Chart Line
OriginalSVG line chart with points and grid

Chart Area
OriginalSVG area chart with gradient fill

Chart Donut
OriginalSVG donut chart with centre label

Chart Pie
OriginalSVG pie chart with legend

Chart Radial
Originalradial progress bars chart

Chart Sparkline
Originalinline sparklines in stat rows

Chart Gauge
Originalsemicircle gauge chart

Chart Stacked Bar
Originalstacked bar chart

Chart Progress
Originalhorizontal progress/metric chart

Chart Heatmap
Originalcontribution-style heatmap grid

Chart Bubble
OriginalSVG bubble/scatter chart

