Web InnoventixFreeCode

Chart Stacked Bar

Original · free

stacked bar chart

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

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

type SeriesDef = {
  key: string;
  label: string;
  short: string;
  bar: string;
  dot: string;
};

const SERIES = [
  {
    key: "subscriptions",
    label: "Subscriptions",
    short: "Subs",
    bar: "bg-indigo-500 dark:bg-indigo-400",
    dot: "bg-indigo-500 dark:bg-indigo-400",
  },
  {
    key: "usage",
    label: "Usage-based",
    short: "Usage",
    bar: "bg-sky-500 dark:bg-sky-400",
    dot: "bg-sky-500 dark:bg-sky-400",
  },
  {
    key: "services",
    label: "Onboarding & services",
    short: "Services",
    bar: "bg-emerald-500 dark:bg-emerald-400",
    dot: "bg-emerald-500 dark:bg-emerald-400",
  },
  {
    key: "addons",
    label: "Add-ons",
    short: "Add-ons",
    bar: "bg-amber-500 dark:bg-amber-400",
    dot: "bg-amber-500 dark:bg-amber-400",
  },
] as const satisfies readonly SeriesDef[];

type SeriesKey = (typeof SERIES)[number]["key"];
type MonthRow = { month: string } & Record<SeriesKey, number>;
type Mode = "abs" | "share";

const DATA: MonthRow[] = [
  { month: "Jan", subscriptions: 82, usage: 24, services: 18, addons: 9 },
  { month: "Feb", subscriptions: 88, usage: 27, services: 16, addons: 11 },
  { month: "Mar", subscriptions: 95, usage: 31, services: 21, addons: 12 },
  { month: "Apr", subscriptions: 103, usage: 34, services: 19, addons: 14 },
  { month: "May", subscriptions: 110, usage: 39, services: 23, addons: 15 },
  { month: "Jun", subscriptions: 121, usage: 44, services: 20, addons: 17 },
];

const CHART_H = 300;
const TICKS = 5;

function niceCeil(v: number): number {
  if (v <= 0) return 10;
  const pow = Math.pow(10, Math.floor(Math.log10(v)));
  const n = v / pow;
  let m: number;
  if (n <= 1) m = 1;
  else if (n <= 2) m = 2;
  else if (n <= 2.5) m = 2.5;
  else if (n <= 5) m = 5;
  else m = 10;
  return m * pow;
}

function fmtMoney(v: number): string {
  if (v >= 1000) return `$${(v / 1000).toFixed(2)}M`;
  return `$${v}k`;
}

type Seg = {
  key: SeriesKey;
  label: string;
  bar: string;
  value: number;
  share: number;
  px: number;
};
type Column = {
  mi: number;
  month: string;
  colSum: number;
  columnPx: number;
  segs: Seg[];
};

export default function StackedBarChart() {
  const reduced = useReducedMotion();
  const uid = useId();
  const [visible, setVisible] = useState<Record<SeriesKey, boolean>>({
    subscriptions: true,
    usage: true,
    services: true,
    addons: true,
  });
  const [mode, setMode] = useState<Mode>("abs");
  const [highlighted, setHighlighted] = useState<SeriesKey | null>(null);
  const [active, setActive] = useState<{ mi: number; key: SeriesKey } | null>(
    null,
  );

  const visibleDefs = SERIES.filter((s) => visible[s.key]);
  const topKey = visibleDefs.length
    ? visibleDefs[visibleDefs.length - 1].key
    : null;

  const view = useMemo(() => {
    let maxColSum = 0;
    for (const row of DATA) {
      let s = 0;
      for (const def of SERIES) if (visible[def.key]) s += row[def.key];
      if (s > maxColSum) maxColSum = s;
    }
    const axisMax = niceCeil(maxColSum);
    const factorAbs = CHART_H / axisMax;

    const columns: Column[] = DATA.map((row, mi) => {
      let colSum = 0;
      for (const def of SERIES) if (visible[def.key]) colSum += row[def.key];
      const segs: Seg[] = SERIES.filter((def) => visible[def.key]).map(
        (def) => {
          const value = row[def.key];
          const share = colSum > 0 ? (value / colSum) * 100 : 0;
          const rawPx =
            mode === "abs"
              ? value * factorAbs
              : colSum > 0
                ? (value / colSum) * CHART_H
                : 0;
          const px = value > 0 ? Math.max(rawPx, 3) : 0;
          return { key: def.key, label: def.label, bar: def.bar, value, share, px };
        },
      );
      const columnPx =
        mode === "abs" ? colSum * factorAbs : colSum > 0 ? CHART_H : 0;
      return { mi, month: row.month, colSum, columnPx, segs };
    });

    const seriesTotals = {} as Record<SeriesKey, number>;
    for (const def of SERIES) {
      let t = 0;
      for (const row of DATA) t += row[def.key];
      seriesTotals[def.key] = t;
    }
    let leadKey: SeriesKey | null = null;
    let leadVal = -1;
    for (const def of SERIES) {
      if (visible[def.key] && seriesTotals[def.key] > leadVal) {
        leadVal = seriesTotals[def.key];
        leadKey = def.key;
      }
    }
    const totalVisible = visibleDefs.reduce(
      (a, def) => a + seriesTotals[def.key],
      0,
    );

    return { axisMax, columns, seriesTotals, leadKey, leadVal, totalVisible };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [visible, mode]);

  const ticks = Array.from({ length: TICKS + 1 }, (_, i) => i / TICKS);
  const leadDef = view.leadKey
    ? SERIES.find((s) => s.key === view.leadKey)
    : null;

  const ease: [number, number, number, number] = [0.22, 0.61, 0.36, 1];
  const transition = reduced ? { duration: 0 } : { duration: 0.55, ease };

  function toggleSeries(key: SeriesKey) {
    setVisible((v) => ({ ...v, [key]: !v[key] }));
    setHighlighted((h) => (h === key ? null : h));
  }

  return (
    <section className="relative w-full overflow-hidden bg-slate-50 px-4 py-16 text-slate-900 sm:px-6 lg:py-24 dark:bg-slate-950 dark:text-slate-100">
      <style>{`
        @keyframes sbcRise { from { opacity: 0; transform: translateY(16px); } to { opacity: 1; transform: none; } }
        @keyframes sbcPop { from { opacity: 0; transform: translate(-50%, 6px) scale(0.96); } to { opacity: 1; transform: translate(-50%, 0) scale(1); } }
        .sbc-anim { animation: sbcRise 0.6s cubic-bezier(0.22,0.61,0.36,1) both; }
        .sbc-pop { animation: sbcPop 0.18s ease-out both; }
        @media (prefers-reduced-motion: reduce) {
          .sbc-anim, .sbc-pop { animation: none !important; }
        }
      `}</style>

      <div
        className="sbc-anim mx-auto w-full max-w-4xl rounded-3xl border border-slate-200 bg-white p-6 shadow-xl shadow-slate-900/5 sm:p-8 dark:border-slate-800 dark:bg-slate-900 dark:shadow-black/40"
      >
        {/* Header */}
        <div className="flex flex-col gap-5 sm:flex-row sm:items-start sm:justify-between">
          <div className="max-w-md">
            <p className="text-xs font-semibold uppercase tracking-[0.2em] text-indigo-600 dark:text-indigo-400">
              Revenue analytics
            </p>
            <h2 className="mt-2 text-2xl font-bold tracking-tight text-slate-900 sm:text-3xl dark:text-slate-50">
              Recurring revenue by stream
            </h2>
            <p className="mt-2 text-sm leading-relaxed text-slate-600 dark:text-slate-400">
              Monthly breakdown across the first half of 2026, in thousands.
              Toggle a stream, hover a segment, or switch to share view to
              compare the mix.
            </p>
          </div>

          {/* Mode toggle */}
          <fieldset className="shrink-0">
            <legend className="sr-only">Chart value mode</legend>
            <div className="inline-flex rounded-xl border border-slate-200 bg-slate-100 p-1 dark:border-slate-700 dark:bg-slate-800">
              {([
                { id: "abs", label: "Absolute" },
                { id: "share", label: "Share %" },
              ] as const).map((opt) => {
                const checked = mode === opt.id;
                return (
                  <label
                    key={opt.id}
                    className={`relative cursor-pointer rounded-lg px-3.5 py-1.5 text-sm font-medium transition-colors has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-slate-900 has-[:focus-visible]:ring-offset-2 has-[:focus-visible]:ring-offset-slate-100 dark:has-[:focus-visible]:ring-white dark:has-[:focus-visible]:ring-offset-slate-800 ${
                      checked
                        ? "bg-white text-slate-900 shadow-sm dark:bg-slate-950 dark:text-slate-50"
                        : "text-slate-500 hover:text-slate-800 dark:text-slate-400 dark:hover:text-slate-200"
                    }`}
                  >
                    <input
                      type="radio"
                      name={`${uid}-mode`}
                      value={opt.id}
                      checked={checked}
                      onChange={() => setMode(opt.id)}
                      className="sr-only"
                    />
                    <span className="pointer-events-none">{opt.label}</span>
                  </label>
                );
              })}
            </div>
          </fieldset>
        </div>

        {/* Stat row */}
        <dl className="mt-6 grid grid-cols-3 gap-3">
          <div className="rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 dark:border-slate-800 dark:bg-slate-800/40">
            <dt className="text-[0.7rem] font-medium uppercase tracking-wide text-slate-500 dark:text-slate-400">
              H1 total shown
            </dt>
            <dd className="mt-0.5 text-lg font-bold tabular-nums text-slate-900 dark:text-slate-50">
              {fmtMoney(view.totalVisible)}
            </dd>
          </div>
          <div className="rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 dark:border-slate-800 dark:bg-slate-800/40">
            <dt className="text-[0.7rem] font-medium uppercase tracking-wide text-slate-500 dark:text-slate-400">
              Leading stream
            </dt>
            <dd className="mt-0.5 flex items-center gap-1.5 text-lg font-bold text-slate-900 dark:text-slate-50">
              {leadDef ? (
                <>
                  <span
                    className={`h-2.5 w-2.5 shrink-0 rounded-full ${leadDef.dot}`}
                    aria-hidden="true"
                  />
                  <span className="truncate text-base">{leadDef.short}</span>
                </>
              ) : (
                <span className="text-base text-slate-400">—</span>
              )}
            </dd>
          </div>
          <div className="rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 dark:border-slate-800 dark:bg-slate-800/40">
            <dt className="text-[0.7rem] font-medium uppercase tracking-wide text-slate-500 dark:text-slate-400">
              Streams shown
            </dt>
            <dd className="mt-0.5 text-lg font-bold tabular-nums text-slate-900 dark:text-slate-50">
              {visibleDefs.length}
              <span className="text-sm font-normal text-slate-400">
                {" "}
                / {SERIES.length}
              </span>
            </dd>
          </div>
        </dl>

        {/* Chart */}
        <figure
          className="mt-8"
          aria-labelledby={`${uid}-cap`}
        >
          <div className="flex gap-3">
            {/* Y axis */}
            <div
              className="relative w-10 shrink-0 sm:w-12"
              style={{ height: CHART_H }}
              aria-hidden="true"
            >
              {ticks.map((frac) => {
                const label =
                  mode === "abs"
                    ? fmtMoney(Math.round(view.axisMax * frac))
                    : `${Math.round(frac * 100)}%`;
                return (
                  <span
                    key={frac}
                    className="absolute right-0 translate-y-1/2 text-[0.65rem] font-medium tabular-nums text-slate-400 dark:text-slate-500"
                    style={{ bottom: frac * CHART_H }}
                  >
                    {label}
                  </span>
                );
              })}
            </div>

            {/* Plot */}
            <div className="min-w-0 flex-1">
              <div className="relative" style={{ height: CHART_H }}>
                {/* Gridlines */}
                {ticks.map((frac) => (
                  <div
                    key={frac}
                    aria-hidden="true"
                    className={`absolute inset-x-0 border-t ${
                      frac === 0
                        ? "border-slate-300 dark:border-slate-600"
                        : "border-slate-200/80 dark:border-slate-700/50"
                    }`}
                    style={{ bottom: frac * CHART_H }}
                  />
                ))}

                {/* Columns */}
                <div className="absolute inset-0 flex items-end gap-2 sm:gap-3">
                  {view.columns.map((col) => (
                    <div
                      key={col.month}
                      className="relative flex h-full min-w-0 flex-1 flex-col justify-end"
                    >
                      {/* Tooltip */}
                      {active?.mi === col.mi &&
                        (() => {
                          const seg = col.segs.find(
                            (s) => s.key === active.key,
                          );
                          if (!seg) return null;
                          return (
                            <div
                              aria-hidden="true"
                              className="sbc-pop pointer-events-none absolute left-1/2 z-20 -translate-x-1/2 whitespace-nowrap rounded-lg border border-slate-200 bg-white px-2.5 py-1.5 text-center shadow-lg dark:border-slate-700 dark:bg-slate-950"
                              style={{ bottom: col.columnPx + 12 }}
                            >
                              <div className="flex items-center gap-1.5 text-xs font-semibold text-slate-900 dark:text-slate-50">
                                <span
                                  className={`h-2 w-2 rounded-full ${seg.bar}`}
                                  aria-hidden="true"
                                />
                                {seg.label}
                              </div>
                              <div className="mt-0.5 text-xs tabular-nums text-slate-500 dark:text-slate-400">
                                {fmtMoney(seg.value)} ·{" "}
                                {Math.round(seg.share)}% of {col.month}
                              </div>
                            </div>
                          );
                        })()}

                      {/* Bar stack */}
                      <div className="flex w-full flex-col-reverse">
                        {col.segs.map((seg) => {
                          const dim =
                            highlighted !== null && highlighted !== seg.key;
                          const isTop = seg.key === topKey;
                          return (
                            <motion.button
                              key={seg.key}
                              type="button"
                              aria-pressed={highlighted === seg.key}
                              aria-label={`${col.month}, ${seg.label}: ${fmtMoney(
                                seg.value,
                              )}, ${Math.round(seg.share)} percent of ${
                                col.month
                              }. Activate to emphasize this stream.`}
                              onMouseEnter={() =>
                                setActive({ mi: col.mi, key: seg.key })
                              }
                              onMouseLeave={() => setActive(null)}
                              onFocus={() =>
                                setActive({ mi: col.mi, key: seg.key })
                              }
                              onBlur={() => setActive(null)}
                              onClick={() =>
                                setHighlighted((h) =>
                                  h === seg.key ? null : seg.key,
                                )
                              }
                              initial={{ height: 0 }}
                              animate={{ height: seg.px, opacity: dim ? 0.35 : 1 }}
                              transition={transition}
                              className={`block w-full cursor-pointer border-t border-black/10 outline-none focus-visible:relative focus-visible:z-10 focus-visible:ring-2 focus-visible:ring-slate-900 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:border-white/10 dark:focus-visible:ring-white dark:focus-visible:ring-offset-slate-900 ${
                                seg.bar
                              } ${isTop ? "rounded-t-md" : ""} ${
                                highlighted === seg.key
                                  ? "ring-2 ring-inset ring-white/50"
                                  : ""
                              }`}
                            />
                          );
                        })}
                      </div>
                    </div>
                  ))}
                </div>

                {/* Empty state */}
                {visibleDefs.length === 0 && (
                  <div className="absolute inset-0 flex items-center justify-center">
                    <p className="rounded-lg bg-slate-100 px-4 py-2 text-sm text-slate-500 dark:bg-slate-800 dark:text-slate-400">
                      No streams selected — enable one below.
                    </p>
                  </div>
                )}
              </div>

              {/* X axis labels */}
              <div className="mt-3 flex gap-2 sm:gap-3">
                {view.columns.map((col) => (
                  <div
                    key={col.month}
                    className="min-w-0 flex-1 text-center text-xs font-medium text-slate-500 dark:text-slate-400"
                  >
                    <span className="block">{col.month}</span>
                    <span className="mt-0.5 block text-[0.7rem] font-semibold tabular-nums text-slate-700 dark:text-slate-300">
                      {mode === "abs"
                        ? fmtMoney(col.colSum)
                        : col.colSum > 0
                          ? "100%"
                          : "—"}
                    </span>
                  </div>
                ))}
              </div>
            </div>
          </div>

          <figcaption id={`${uid}-cap`} className="sr-only">
            Stacked bar chart of monthly recurring revenue by stream for the
            first half of 2026. Streams: Subscriptions, Usage-based, Onboarding
            and services, and Add-ons.
          </figcaption>
        </figure>

        {/* Legend */}
        <div className="mt-6 flex flex-wrap items-center gap-2 border-t border-slate-200 pt-5 dark:border-slate-800">
          <span className="mr-1 text-xs font-medium uppercase tracking-wide text-slate-400 dark:text-slate-500">
            Streams
          </span>
          {SERIES.map((def) => {
            const on = visible[def.key];
            return (
              <button
                key={def.key}
                type="button"
                aria-pressed={on}
                onClick={() => toggleSeries(def.key)}
                className={`inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-sm font-medium outline-none transition-colors focus-visible:ring-2 focus-visible:ring-slate-900 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-white dark:focus-visible:ring-offset-slate-900 ${
                  on
                    ? "border-slate-300 bg-white text-slate-800 hover:bg-slate-50 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-100 dark:hover:bg-slate-700"
                    : "border-slate-200 bg-transparent text-slate-400 hover:text-slate-600 dark:border-slate-800 dark:text-slate-500 dark:hover:text-slate-300"
                }`}
              >
                <span
                  className={`h-2.5 w-2.5 rounded-full transition-opacity ${def.dot} ${
                    on ? "opacity-100" : "opacity-30"
                  }`}
                  aria-hidden="true"
                />
                <span className={on ? "" : "line-through decoration-slate-400"}>
                  {def.label}
                </span>
                <span className="tabular-nums text-xs text-slate-400 dark:text-slate-500">
                  {fmtMoney(view.seriesTotals[def.key])}
                </span>
              </button>
            );
          })}
          {highlighted && (
            <button
              type="button"
              onClick={() => setHighlighted(null)}
              className="ml-auto rounded-full px-3 py-1.5 text-xs font-medium text-indigo-600 underline-offset-2 outline-none hover:underline focus-visible:ring-2 focus-visible:ring-slate-900 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:text-indigo-400 dark:focus-visible:ring-white dark:focus-visible:ring-offset-slate-900"
            >
              Clear emphasis
            </button>
          )}
        </div>

        {/* Screen-reader data table */}
        <table className="sr-only">
          <caption>
            Monthly recurring revenue by stream, in thousands of dollars, H1
            2026.
          </caption>
          <thead>
            <tr>
              <th scope="col">Month</th>
              {SERIES.map((def) => (
                <th key={def.key} scope="col">
                  {def.label}
                </th>
              ))}
              <th scope="col">Total</th>
            </tr>
          </thead>
          <tbody>
            {DATA.map((row) => {
              const total = SERIES.reduce((a, d) => a + row[d.key], 0);
              return (
                <tr key={row.month}>
                  <th scope="row">{row.month}</th>
                  {SERIES.map((def) => (
                    <td key={def.key}>{fmtMoney(row[def.key])}</td>
                  ))}
                  <td>{fmtMoney(total)}</td>
                </tr>
              );
            })}
          </tbody>
        </table>
      </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 →