Web InnoventixFreeCode

Chart Bar Grouped

Original · free

grouped bar chart with legend

byWeb InnoventixReact + Tailwind
chartbargroupedcharts
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-grouped.json
chart-bar-grouped.tsx
"use client";

import { useMemo, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

type Region = "americas" | "emea" | "apac";

type Datum = { quarter: string } & Record<Region, number>;

type SeriesDef = {
  key: Region;
  label: string;
  fill: string;
  dot: string;
};

const DATA: Datum[] = [
  { quarter: "Q1", americas: 42, emea: 31, apac: 18 },
  { quarter: "Q2", americas: 48, emea: 34, apac: 22 },
  { quarter: "Q3", americas: 51, emea: 38, apac: 27 },
  { quarter: "Q4", americas: 59, emea: 41, apac: 33 },
];

const SERIES: SeriesDef[] = [
  { key: "americas", label: "Americas", fill: "fill-indigo-500 dark:fill-indigo-400", dot: "bg-indigo-500 dark:bg-indigo-400" },
  { key: "emea", label: "EMEA", fill: "fill-emerald-500 dark:fill-emerald-400", dot: "bg-emerald-500 dark:bg-emerald-400" },
  { key: "apac", label: "APAC", fill: "fill-amber-500 dark:fill-amber-400", dot: "bg-amber-500 dark:bg-amber-400" },
];

const W = 720;
const H = 380;
const PLOT = { x: 52, y: 28, w: 648, h: 288 };
const BASE = PLOT.y + PLOT.h;
const NICE_MAX = 60;
const TICKS = [0, 15, 30, 45, 60];
const BAND = PLOT.w / DATA.length;

type Bar = {
  gi: number;
  key: Region;
  label: string;
  fill: string;
  value: number;
  x: number;
  y: number;
  w: number;
  h: number;
  cx: number;
};

type Active = { gi: number; key: Region } | null;

function tickY(v: number) {
  return PLOT.y + PLOT.h - (v / NICE_MAX) * PLOT.h;
}

export default function ChartBarGrouped() {
  const reduce = useReducedMotion();
  const [hidden, setHidden] = useState<Set<Region>>(() => new Set());
  const [active, setActive] = useState<Active>(null);
  const [showValues, setShowValues] = useState(false);

  const bars = useMemo<Bar[]>(() => {
    const visible = SERIES.filter((s) => !hidden.has(s.key));
    const count = visible.length;
    const gap = 6;
    const inner = BAND * 0.62;
    const out: Bar[] = [];
    DATA.forEach((d, gi) => {
      const bandX = PLOT.x + gi * BAND;
      const start = bandX + (BAND - inner) / 2;
      const bw = count > 0 ? (inner - gap * (count - 1)) / count : 0;
      visible.forEach((s, i) => {
        const value = d[s.key];
        const barH = (value / NICE_MAX) * PLOT.h;
        const x = start + i * (bw + gap);
        out.push({
          gi,
          key: s.key,
          label: s.label,
          fill: s.fill,
          value,
          x,
          y: BASE - barH,
          w: bw,
          h: barH,
          cx: x + bw / 2,
        });
      });
    });
    return out;
  }, [hidden]);

  const activeBar = active ? bars.find((b) => b.gi === active.gi && b.key === active.key) : undefined;

  const toggle = (key: Region) => {
    setActive(null);
    setHidden((prev) => {
      const next = new Set(prev);
      if (next.has(key)) {
        next.delete(key);
      } else {
        if (SERIES.length - next.size <= 1) return prev;
        next.add(key);
      }
      return next;
    });
  };

  const totals = SERIES.map((s) => ({
    key: s.key,
    total: DATA.reduce((sum, d) => sum + d[s.key], 0),
  }));

  const transition = { duration: reduce ? 0 : 0.5, ease: "easeOut" as const };

  const tipW = 118;
  const tipH = 46;
  let tipX = 0;
  let tipY = 0;
  let tipBelow = false;
  if (activeBar) {
    tipX = Math.max(4, Math.min(W - tipW - 4, activeBar.cx - tipW / 2));
    tipY = activeBar.y - tipH - 10;
    if (tipY < 2) {
      tipY = activeBar.y + activeBar.h + 10;
      tipBelow = true;
    }
  }

  return (
    <section className="relative w-full bg-slate-50 px-4 py-16 sm:py-20 dark:bg-slate-950">
      <style>{`
        @keyframes chartbg-pop {
          from { opacity: 0; }
          to { opacity: 1; }
        }
        .chartbg-tip { animation: chartbg-pop .18s ease-out both; }
        .chartbg-bar { outline: none; cursor: pointer; }
        .chartbg-bar:focus-visible { outline: 2px solid #4f46e5; outline-offset: 2px; }
        @media (prefers-color-scheme: dark) {
          .chartbg-bar:focus-visible { outline-color: #a5b4fc; }
        }
        @media (prefers-reduced-motion: reduce) {
          .chartbg-tip { animation: none; }
        }
      `}</style>

      <div className="mx-auto max-w-3xl">
        <figure className="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm sm:p-7 dark:border-slate-800 dark:bg-slate-900">
          <figcaption className="mb-5 flex flex-col gap-1">
            <h2 id="chartbg-title" className="text-lg font-semibold tracking-tight text-slate-900 dark:text-white">
              Cloud revenue by region
            </h2>
            <p className="text-sm text-slate-500 dark:text-slate-400">
              Annual recurring revenue, FY2025 ($M), grouped by fiscal quarter
            </p>
          </figcaption>

          <div className="mb-4 flex flex-wrap items-center gap-2">
            {SERIES.map((s) => {
              const isVisible = !hidden.has(s.key);
              const isLast = SERIES.length - hidden.size <= 1 && isVisible;
              const total = totals.find((t) => t.key === s.key)?.total ?? 0;
              return (
                <button
                  key={s.key}
                  type="button"
                  aria-pressed={isVisible}
                  disabled={isLast}
                  onClick={() => toggle(s.key)}
                  className="inline-flex items-center gap-2 rounded-full border border-slate-200 px-3 py-1.5 text-sm font-medium text-slate-700 transition hover:bg-slate-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-70 dark:border-slate-700 dark:text-slate-200 dark:ring-offset-slate-900 dark:hover:bg-slate-800"
                >
                  <span className={`h-3 w-3 rounded-[3px] ${s.dot} ${isVisible ? "" : "opacity-30"}`} aria-hidden="true" />
                  <span className={isVisible ? "" : "text-slate-400 line-through dark:text-slate-500"}>{s.label}</span>
                  <span className="tabular-nums text-xs text-slate-400 dark:text-slate-500">${total}M</span>
                </button>
              );
            })}

            <label className="ml-auto inline-flex cursor-pointer items-center gap-2 rounded-full px-2 py-1.5 text-sm font-medium text-slate-600 focus-within:ring-2 focus-within:ring-indigo-500 focus-within:ring-offset-2 dark:text-slate-300 dark:ring-offset-slate-900">
              <input
                type="checkbox"
                checked={showValues}
                onChange={(e) => setShowValues(e.target.checked)}
                className="h-4 w-4 rounded border-slate-300 accent-indigo-600 focus:outline-none dark:border-slate-600"
              />
              Show values
            </label>
          </div>

          <div className="w-full overflow-x-auto">
            <svg
              viewBox={`0 0 ${W} ${H}`}
              width="100%"
              role="group"
              aria-labelledby="chartbg-title"
              className="min-w-[520px] select-none"
            >
              {TICKS.map((t) => (
                <g key={t}>
                  <line
                    x1={PLOT.x}
                    x2={PLOT.x + PLOT.w}
                    y1={tickY(t)}
                    y2={tickY(t)}
                    className={t === 0 ? "stroke-slate-300 dark:stroke-slate-700" : "stroke-slate-200 dark:stroke-slate-800"}
                    strokeWidth={1}
                  />
                  <text
                    x={PLOT.x - 10}
                    y={tickY(t) + 4}
                    textAnchor="end"
                    className="fill-slate-400 text-[12px] tabular-nums dark:fill-slate-500"
                  >
                    {t}
                  </text>
                </g>
              ))}

              <text
                x={PLOT.x - 10}
                y={PLOT.y - 12}
                textAnchor="end"
                className="fill-slate-400 text-[11px] font-medium dark:fill-slate-500"
              >
                $M
              </text>

              {DATA.map((d, gi) => (
                <text
                  key={d.quarter}
                  x={PLOT.x + gi * BAND + BAND / 2}
                  y={BASE + 26}
                  textAnchor="middle"
                  className="fill-slate-600 text-[13px] font-medium dark:fill-slate-300"
                >
                  {d.quarter}
                </text>
              ))}

              <AnimatePresence>
                {bars.map((b) => {
                  const dim = activeBar !== undefined && !(activeBar.gi === b.gi && activeBar.key === b.key);
                  return (
                    <motion.rect
                      key={`${b.gi}-${b.key}`}
                      className={`chartbg-bar ${b.fill}`}
                      rx={3}
                      role="img"
                      tabIndex={0}
                      aria-label={`${DATA[b.gi].quarter}, ${b.label}: $${b.value} million`}
                      initial={reduce ? false : { x: b.x, width: b.w, y: BASE, height: 0, fillOpacity: 1 }}
                      animate={{ x: b.x, width: b.w, y: b.y, height: b.h, fillOpacity: dim ? 0.3 : 1 }}
                      exit={{ height: 0, y: BASE, opacity: 0, pointerEvents: "none" }}
                      transition={transition}
                      onMouseEnter={() => setActive({ gi: b.gi, key: b.key })}
                      onMouseLeave={() => setActive(null)}
                      onFocus={() => setActive({ gi: b.gi, key: b.key })}
                      onBlur={() => setActive(null)}
                    />
                  );
                })}
              </AnimatePresence>

              {showValues &&
                bars.map((b) => (
                  <text
                    key={`v-${b.gi}-${b.key}`}
                    x={b.cx}
                    y={b.y - 6}
                    textAnchor="middle"
                    className="fill-slate-500 text-[11px] font-semibold tabular-nums dark:fill-slate-400"
                  >
                    {b.value}
                  </text>
                ))}

              {activeBar && (
                <rect
                  x={activeBar.x - 2}
                  y={activeBar.y - 2}
                  width={activeBar.w + 4}
                  height={activeBar.h + 4}
                  rx={4}
                  fill="none"
                  strokeWidth={2}
                  className="stroke-slate-900 dark:stroke-white"
                  pointerEvents="none"
                />
              )}

              {activeBar && (
                <g className="chartbg-tip" pointerEvents="none">
                  <rect
                    x={tipX}
                    y={tipY}
                    width={tipW}
                    height={tipH}
                    rx={8}
                    className="fill-slate-900 dark:fill-white"
                  />
                  <text
                    x={tipX + tipW / 2}
                    y={tipY + 18}
                    textAnchor="middle"
                    className="fill-slate-300 text-[11px] dark:fill-slate-600"
                  >
                    {DATA[activeBar.gi].quarter} · {activeBar.label}
                  </text>
                  <text
                    x={tipX + tipW / 2}
                    y={tipY + 36}
                    textAnchor="middle"
                    className="fill-white text-[14px] font-semibold tabular-nums dark:fill-slate-900"
                  >
                    ${activeBar.value}M ARR
                  </text>
                  {!tipBelow && (
                    <path
                      d={`M ${activeBar.cx - 6} ${tipY + tipH} L ${activeBar.cx + 6} ${tipY + tipH} L ${activeBar.cx} ${tipY + tipH + 7} Z`}
                      className="fill-slate-900 dark:fill-white"
                    />
                  )}
                </g>
              )}
            </svg>
          </div>

          <p className="mt-4 text-xs text-slate-400 dark:text-slate-500">
            Toggle a region in the legend to isolate its trend. Hover or focus a bar for exact figures.
          </p>

          <table className="sr-only">
            <caption>Cloud annual recurring revenue by region and fiscal quarter, FY2025, in millions of dollars.</caption>
            <thead>
              <tr>
                <th scope="col">Quarter</th>
                {SERIES.map((s) => (
                  <th key={s.key} scope="col">
                    {s.label}
                  </th>
                ))}
              </tr>
            </thead>
            <tbody>
              {DATA.map((d) => (
                <tr key={d.quarter}>
                  <th scope="row">{d.quarter}</th>
                  {SERIES.map((s) => (
                    <td key={s.key}>${d[s.key]}M</td>
                  ))}
                </tr>
              ))}
            </tbody>
          </table>
        </figure>
      </div>
    </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 →