Web InnoventixFreeCode

Multi Progress

Original · free

multi-segment stacked progress

byWeb InnoventixReact + Tailwind
progmultiprogress
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/prog-multi.json
prog-multi.tsx
"use client";

import { useId, useRef, useState, type KeyboardEvent } from "react";
import { motion, useReducedMotion } from "motion/react";

type CategoryId = "media" | "docs" | "apps" | "system" | "other";

type Category = {
  id: CategoryId;
  label: string;
  hint: string;
  bar: string;
  dot: string;
  focus: string;
};

type Device = {
  name: string;
  sub: string;
  capacity: number; // in GB
  values: Record<CategoryId, number>;
};

const CATEGORIES: Category[] = [
  {
    id: "media",
    label: "Photos & Video",
    hint: "12,480 items",
    bar: "bg-sky-500 dark:bg-sky-400",
    dot: "bg-sky-500 dark:bg-sky-400",
    focus: "focus-visible:ring-sky-500 dark:focus-visible:ring-sky-400",
  },
  {
    id: "docs",
    label: "Documents",
    hint: "3,204 files",
    bar: "bg-violet-500 dark:bg-violet-400",
    dot: "bg-violet-500 dark:bg-violet-400",
    focus: "focus-visible:ring-violet-500 dark:focus-visible:ring-violet-400",
  },
  {
    id: "apps",
    label: "Applications",
    hint: "168 installed",
    bar: "bg-emerald-500 dark:bg-emerald-400",
    dot: "bg-emerald-500 dark:bg-emerald-400",
    focus: "focus-visible:ring-emerald-500 dark:focus-visible:ring-emerald-400",
  },
  {
    id: "system",
    label: "System Data",
    hint: "caches & logs",
    bar: "bg-amber-500 dark:bg-amber-400",
    dot: "bg-amber-500 dark:bg-amber-400",
    focus: "focus-visible:ring-amber-500 dark:focus-visible:ring-amber-400",
  },
  {
    id: "other",
    label: "Other",
    hint: "misc & trash",
    bar: "bg-rose-500 dark:bg-rose-400",
    dot: "bg-rose-500 dark:bg-rose-400",
    focus: "focus-visible:ring-rose-500 dark:focus-visible:ring-rose-400",
  },
];

const DEVICES: Device[] = [
  {
    name: "MacBook Pro",
    sub: "Internal SSD · 1 TB",
    capacity: 1024,
    values: { media: 214, docs: 96, apps: 148, system: 38, other: 52 },
  },
  {
    name: "iCloud+",
    sub: "2 TB plan",
    capacity: 2048,
    values: { media: 742, docs: 205, apps: 44, system: 12, other: 96 },
  },
  {
    name: "Time Machine",
    sub: "External · 4 TB",
    capacity: 4096,
    values: { media: 1180, docs: 320, apps: 210, system: 64, other: 148 },
  },
];

function formatSize(gb: number): string {
  if (gb >= 1024) {
    const tb = gb / 1024;
    return `${Number.isInteger(tb) ? tb.toFixed(0) : tb.toFixed(2)} TB`;
  }
  return `${gb.toFixed(0)} GB`;
}

export default function ProgMulti() {
  const uid = useId();
  const reduce = useReducedMotion();

  const [activeIndex, setActiveIndex] = useState(0);
  const [hidden, setHidden] = useState<Set<CategoryId>>(new Set());
  const [hovered, setHovered] = useState<CategoryId | null>(null);

  const tabRefs = useRef<Array<HTMLButtonElement | null>>([]);

  const device = DEVICES[activeIndex];

  const usedVisible = CATEGORIES.reduce(
    (sum, c) => (hidden.has(c.id) ? sum : sum + device.values[c.id]),
    0,
  );
  const free = Math.max(device.capacity - usedVisible, 0);
  const usedPct = (usedVisible / device.capacity) * 100;

  const hoveredCat = hovered ? CATEGORIES.find((c) => c.id === hovered) ?? null : null;

  function toggle(id: CategoryId) {
    setHidden((prev) => {
      const next = new Set(prev);
      if (next.has(id)) next.delete(id);
      else next.add(id);
      return next;
    });
  }

  function onTabKeyDown(e: KeyboardEvent<HTMLButtonElement>, i: number) {
    const last = DEVICES.length - 1;
    let next = i;
    if (e.key === "ArrowRight" || e.key === "ArrowDown") next = i === last ? 0 : i + 1;
    else if (e.key === "ArrowLeft" || e.key === "ArrowUp") next = i === 0 ? last : i - 1;
    else if (e.key === "Home") next = 0;
    else if (e.key === "End") next = last;
    else return;
    e.preventDefault();
    setActiveIndex(next);
    tabRefs.current[next]?.focus();
  }

  const keyframes = `
@keyframes progmulti-pulse {
  0%, 100% { opacity: 0.35; transform: scaleY(0.82); }
  50% { opacity: 0.95; transform: scaleY(1); }
}
@keyframes progmulti-rise {
  from { opacity: 0; transform: translateY(6px); }
  to { opacity: 1; transform: translateY(0); }
}
@media (prefers-reduced-motion: reduce) {
  .progmulti-anim { animation: none !important; }
}
`;

  const panelId = `${uid}-panel`;

  return (
    <section
      aria-labelledby={`${uid}-title`}
      className="relative w-full overflow-hidden bg-slate-50 text-slate-900 dark:bg-slate-950 dark:text-slate-100"
    >
      <style>{keyframes}</style>

      {/* atmosphere */}
      <div
        aria-hidden
        className="pointer-events-none absolute -top-24 right-0 h-72 w-72 rounded-full bg-indigo-400/10 blur-3xl dark:bg-indigo-500/10"
      />
      <div
        aria-hidden
        className="pointer-events-none absolute -bottom-24 -left-10 h-72 w-72 rounded-full bg-sky-400/10 blur-3xl dark:bg-sky-500/10"
      />

      <div className="relative mx-auto max-w-2xl px-6 py-16 sm:py-24">
        <div className="rounded-3xl border border-slate-200 bg-white/80 p-6 shadow-xl shadow-slate-900/5 backdrop-blur-sm sm:p-8 dark:border-slate-800 dark:bg-slate-900/70 dark:shadow-black/30">
          {/* header */}
          <div className="flex flex-wrap items-start justify-between gap-4">
            <div>
              <p className="text-[0.7rem] font-semibold uppercase tracking-[0.22em] text-indigo-600 dark:text-indigo-400">
                Storage overview
              </p>
              <h2
                id={`${uid}-title`}
                className="mt-1.5 text-2xl font-semibold tracking-tight text-slate-900 sm:text-3xl dark:text-white"
              >
                {device.name}
              </h2>
              <p className="mt-1 text-sm text-slate-500 dark:text-slate-400">{device.sub}</p>
            </div>

            <div className="text-right">
              <div className="flex items-baseline justify-end gap-1">
                <span className="text-3xl font-semibold tabular-nums tracking-tight text-slate-900 sm:text-4xl dark:text-white">
                  {Math.round(usedPct)}
                </span>
                <span className="text-lg font-medium text-slate-400 dark:text-slate-500">%</span>
              </div>
              <p className="text-xs font-medium uppercase tracking-wider text-slate-400 dark:text-slate-500">
                in use
              </p>
            </div>
          </div>

          {/* device tabs */}
          <div
            role="tablist"
            aria-label="Choose a device"
            className="mt-6 inline-flex rounded-full border border-slate-200 bg-slate-100 p-1 dark:border-slate-800 dark:bg-slate-800/60"
          >
            {DEVICES.map((d, i) => {
              const selected = i === activeIndex;
              return (
                <button
                  key={d.name}
                  ref={(el) => {
                    tabRefs.current[i] = el;
                  }}
                  role="tab"
                  type="button"
                  id={`${uid}-tab-${i}`}
                  aria-selected={selected}
                  aria-controls={panelId}
                  tabIndex={selected ? 0 : -1}
                  onClick={() => setActiveIndex(i)}
                  onKeyDown={(e) => onTabKeyDown(e, i)}
                  className={`rounded-full px-3.5 py-1.5 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-100 dark:focus-visible:ring-offset-slate-900 ${
                    selected
                      ? "bg-white text-slate-900 shadow-sm dark:bg-slate-700 dark:text-white"
                      : "text-slate-500 hover:text-slate-800 dark:text-slate-400 dark:hover:text-slate-200"
                  }`}
                >
                  {d.name}
                </button>
              );
            })}
          </div>

          {/* panel */}
          <div id={panelId} role="tabpanel" aria-labelledby={`${uid}-tab-${activeIndex}`} tabIndex={0} className="mt-6 focus-visible:outline-none">
            {/* stacked bar */}
            <div
              role="progressbar"
              aria-valuenow={Math.round(usedPct)}
              aria-valuemin={0}
              aria-valuemax={100}
              aria-valuetext={`${formatSize(usedVisible)} used of ${formatSize(device.capacity)}`}
              aria-label={`${device.name} storage in use`}
              className="relative flex h-6 w-full overflow-hidden rounded-full bg-slate-200 shadow-inner dark:bg-slate-800"
            >
              {CATEGORIES.map((c) => {
                const isHidden = hidden.has(c.id);
                const widthPct = isHidden ? 0 : (device.values[c.id] / device.capacity) * 100;
                const dimmed = hovered !== null && hovered !== c.id;
                return (
                  <motion.div
                    key={c.id}
                    aria-hidden
                    title={`${c.label} — ${formatSize(device.values[c.id])}`}
                    className={`h-full ${c.bar} transition-opacity duration-200`}
                    style={{ opacity: dimmed ? 0.32 : 1 }}
                    initial={reduce ? false : { width: 0 }}
                    animate={{ width: `${widthPct}%` }}
                    transition={
                      reduce ? { duration: 0 } : { type: "spring", stiffness: 140, damping: 22 }
                    }
                  />
                );
              })}

              {/* boundary marker */}
              {usedPct > 1 && usedPct < 99 && (
                <motion.div
                  aria-hidden
                  className="progmulti-anim absolute top-0 h-full w-[2px] rounded bg-slate-900/70 dark:bg-white/80 [animation:progmulti-pulse_2.4s_ease-in-out_infinite]"
                  initial={reduce ? false : { left: 0 }}
                  animate={{ left: `calc(${usedPct}% - 1px)` }}
                  transition={
                    reduce ? { duration: 0 } : { type: "spring", stiffness: 140, damping: 22 }
                  }
                />
              )}
            </div>

            {/* under-bar summary (live) */}
            <p
              aria-live="polite"
              className="mt-3 min-h-[1.25rem] text-sm text-slate-600 dark:text-slate-300"
            >
              {hoveredCat ? (
                <span className="progmulti-anim [animation:progmulti-rise_240ms_ease-out]">
                  <span className="font-semibold text-slate-900 dark:text-white">
                    {hoveredCat.label}
                  </span>
                  {" — "}
                  {formatSize(device.values[hoveredCat.id])}
                  <span className="text-slate-400 dark:text-slate-500">
                    {" · "}
                    {((device.values[hoveredCat.id] / device.capacity) * 100).toFixed(1)}% of drive
                  </span>
                </span>
              ) : (
                <>
                  <span className="font-semibold text-slate-900 dark:text-white">
                    {formatSize(usedVisible)}
                  </span>{" "}
                  used
                  <span className="text-slate-400 dark:text-slate-500">
                    {" · "}
                    {formatSize(free)} available of {formatSize(device.capacity)}
                  </span>
                </>
              )}
            </p>

            {/* legend header */}
            <div className="mt-6 flex items-center justify-between">
              <h3 className="text-xs font-semibold uppercase tracking-[0.16em] text-slate-500 dark:text-slate-400">
                Breakdown
              </h3>
              {hidden.size > 0 && (
                <button
                  type="button"
                  onClick={() => setHidden(new Set())}
                  className="rounded-md text-xs font-medium text-indigo-600 underline-offset-2 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:text-indigo-400 dark:focus-visible:ring-offset-slate-900"
                >
                  Show all
                </button>
              )}
            </div>

            {/* legend / toggles */}
            <ul className="mt-3 grid grid-cols-1 gap-2 sm:grid-cols-2">
              {CATEGORIES.map((c) => {
                const isHidden = hidden.has(c.id);
                const value = device.values[c.id];
                const pct = (value / device.capacity) * 100;
                return (
                  <li key={c.id}>
                    <button
                      type="button"
                      aria-pressed={!isHidden}
                      onClick={() => toggle(c.id)}
                      onMouseEnter={() => setHovered(c.id)}
                      onMouseLeave={() => setHovered((h) => (h === c.id ? null : h))}
                      onFocus={() => setHovered(c.id)}
                      onBlur={() => setHovered((h) => (h === c.id ? null : h))}
                      className={`flex w-full items-center gap-3 rounded-xl border px-3 py-2.5 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900 ${c.focus} ${
                        isHidden
                          ? "border-slate-200 bg-slate-50 dark:border-slate-800 dark:bg-slate-900/40"
                          : "border-slate-200 bg-white hover:border-slate-300 dark:border-slate-700 dark:bg-slate-800/50 dark:hover:border-slate-600"
                      }`}
                    >
                      <span
                        aria-hidden
                        className={`mt-0.5 h-3 w-3 flex-shrink-0 rounded-sm transition-all ${
                          isHidden
                            ? "bg-transparent ring-1 ring-inset ring-slate-300 dark:ring-slate-600"
                            : c.dot
                        }`}
                      />
                      <span className="min-w-0 flex-1">
                        <span
                          className={`block truncate text-sm font-medium ${
                            isHidden
                              ? "text-slate-400 line-through dark:text-slate-500"
                              : "text-slate-800 dark:text-slate-100"
                          }`}
                        >
                          {c.label}
                        </span>
                        <span className="block truncate text-xs text-slate-400 dark:text-slate-500">
                          {c.hint}
                        </span>
                      </span>
                      <span className="flex-shrink-0 text-right">
                        <span
                          className={`block text-sm font-semibold tabular-nums ${
                            isHidden
                              ? "text-slate-400 dark:text-slate-500"
                              : "text-slate-900 dark:text-white"
                          }`}
                        >
                          {formatSize(value)}
                        </span>
                        <span className="block text-xs tabular-nums text-slate-400 dark:text-slate-500">
                          {pct.toFixed(1)}%
                        </span>
                      </span>
                    </button>
                  </li>
                );
              })}
            </ul>

            <p className="mt-5 text-xs text-slate-400 dark:text-slate-500">
              Select a category to include or exclude it from the total. Use{" "}
              <kbd className="rounded border border-slate-300 bg-slate-100 px-1 font-sans text-[0.65rem] text-slate-600 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300">
                ←
              </kbd>{" "}
              <kbd className="rounded border border-slate-300 bg-slate-100 px-1 font-sans text-[0.65rem] text-slate-600 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300">
                →
              </kbd>{" "}
              to switch devices.
            </p>
          </div>
        </div>
      </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 →