Web InnoventixFreeCode

Chart Bar

Original · free

hand-built SVG/div bar chart with axes

byWeb InnoventixReact + Tailwind
chartbarcharts
Open

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.json
chart-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 quote

Similar components

Browse all →