Web InnoventixFreeCode

Label Inside Progress

Original · free

progress bar with inner label

byWeb InnoventixReact + Tailwind
proglabelinsideprogress
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-label-inside.json
prog-label-inside.tsx
"use client";

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

type Status = "idle" | "running" | "paused" | "done";
type Speed = 0.5 | 1 | 2;
type Tone = "indigo" | "emerald" | "sky";

const SPEEDS: Speed[] = [0.5, 1, 2];
const TOTAL_FRAMES = 5400;
const TOTAL_MB = 214.6;

function phaseName(pct: number): string {
  if (pct >= 100) return "Export complete";
  if (pct >= 80) return "Finalizing MP4";
  if (pct >= 55) return "Mixing audio";
  if (pct >= 25) return "Encoding video";
  return "Analyzing frames";
}

function useTrackWidth() {
  const ref = useRef<HTMLDivElement | null>(null);
  const [width, setWidth] = useState(0);
  useEffect(() => {
    const el = ref.current;
    if (!el) return;
    const ro = new ResizeObserver((entries) => {
      for (const entry of entries) setWidth(entry.contentRect.width);
    });
    ro.observe(el);
    setWidth(el.getBoundingClientRect().width);
    return () => ro.disconnect();
  }, []);
  return { ref, width };
}

const TONES: Record<Tone, string> = {
  indigo: "from-indigo-500 to-violet-500",
  emerald: "from-emerald-500 to-emerald-400",
  sky: "from-sky-500 to-indigo-500",
};

function StatBar({ label, value, tone }: { label: string; value: number; tone: Tone }) {
  const reduce = useReducedMotion();
  const { ref, width } = useTrackWidth();
  const v = Math.max(0, Math.min(100, value));
  const text = `${label} · ${Math.round(v)}%`;
  return (
    <div
      ref={ref}
      role="progressbar"
      aria-label={label}
      aria-valuemin={0}
      aria-valuemax={100}
      aria-valuenow={Math.round(v)}
      className="relative h-7 w-full overflow-hidden rounded-full bg-slate-200 ring-1 ring-inset ring-slate-300/70 dark:bg-slate-800 dark:ring-slate-700/70"
    >
      <div className="absolute inset-0 flex items-center justify-center px-3">
        <span className="whitespace-nowrap text-[11px] font-medium tracking-tight text-slate-600 tabular-nums dark:text-slate-300">
          {text}
        </span>
      </div>
      <motion.div
        className={`absolute inset-y-0 left-0 overflow-hidden rounded-full bg-gradient-to-r ${TONES[tone]}`}
        initial={false}
        animate={{ width: `${v}%` }}
        transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 150, damping: 26 }}
      >
        <div
          className="absolute inset-y-0 left-0 flex items-center justify-center px-3"
          style={{ width: width || undefined }}
        >
          <span className="whitespace-nowrap text-[11px] font-semibold tracking-tight text-white tabular-nums">
            {text}
          </span>
        </div>
      </motion.div>
    </div>
  );
}

function IconPlay({ className }: { className?: string }) {
  return (
    <svg viewBox="0 0 24 24" aria-hidden="true" className={className}>
      <path d="M8 5.5v13l11-6.5-11-6.5z" fill="currentColor" />
    </svg>
  );
}
function IconPause({ className }: { className?: string }) {
  return (
    <svg viewBox="0 0 24 24" aria-hidden="true" className={className}>
      <rect x="7" y="5.5" width="3.4" height="13" rx="1" fill="currentColor" />
      <rect x="13.6" y="5.5" width="3.4" height="13" rx="1" fill="currentColor" />
    </svg>
  );
}
function IconReplay({ className }: { className?: string }) {
  return (
    <svg
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={1.9}
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
      className={className}
    >
      <path d="M4 12a8 8 0 1 1 2.5 5.8" />
      <path d="M4 20v-4h4" />
    </svg>
  );
}
function IconReset({ className }: { className?: string }) {
  return (
    <svg
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={1.9}
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
      className={className}
    >
      <path d="M9 4H5v4" />
      <path d="M5 8a8 8 0 1 1-1.2 6" />
    </svg>
  );
}
function IconClapper({ className }: { className?: string }) {
  return (
    <svg
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={1.7}
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
      className={className}
    >
      <path d="M3 8h18v11a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V8z" />
      <path d="M3 8l2.4-3.4 3.4.9L6.4 8.9 3 8z" />
      <path d="M8.2 5.5l3.4.9-2.4 3.4" />
      <path d="M13.4 6.7l3.4.9-2.4 3.4" />
    </svg>
  );
}
function IconFilm({ className }: { className?: string }) {
  return (
    <svg
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={1.7}
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
      className={className}
    >
      <rect x="4" y="4" width="16" height="16" rx="2" />
      <path d="M8 4v16M16 4v16M4 9h4M16 9h4M4 15h4M16 15h4" />
    </svg>
  );
}
function IconDrive({ className }: { className?: string }) {
  return (
    <svg
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={1.7}
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
      className={className}
    >
      <rect x="3" y="13" width="18" height="7" rx="2" />
      <path d="M6 13l2.2-8h7.6L18 13" />
      <circle cx="7.5" cy="16.5" r="0.9" fill="currentColor" stroke="none" />
    </svg>
  );
}
function IconClock({ className }: { className?: string }) {
  return (
    <svg
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={1.7}
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
      className={className}
    >
      <circle cx="12" cy="12" r="8.5" />
      <path d="M12 7.5V12l3 2" />
    </svg>
  );
}

export default function ProgLabelInside() {
  const reduce = useReducedMotion();
  const [progress, setProgress] = useState(0);
  const [status, setStatus] = useState<Status>("idle");
  const [speed, setSpeed] = useState<Speed>(1);
  const [announce, setAnnounce] = useState("Ready to export Sunset_Reel_v4.mp4.");

  const { ref: trackRef, width: trackW } = useTrackWidth();
  const speedRefs = useRef<Array<HTMLButtonElement | null>>([]);
  const lastPhase = useRef("");

  useEffect(() => {
    if (status !== "running") return;
    const id = window.setInterval(() => {
      setProgress((p) => Math.min(100, p + 0.8 * speed));
    }, 110);
    return () => window.clearInterval(id);
  }, [status, speed]);

  useEffect(() => {
    if (progress >= 100 && status === "running") setStatus("done");
  }, [progress, status]);

  useEffect(() => {
    if (status === "done") {
      setAnnounce("Export complete. Sunset_Reel_v4.mp4 is ready to download.");
      return;
    }
    const ph = phaseName(progress);
    if (ph !== lastPhase.current && status === "running") {
      lastPhase.current = ph;
      setAnnounce(`${ph}, ${Math.round(progress)} percent complete.`);
    }
  }, [progress, status]);

  const clamped = Math.max(0, Math.min(100, progress));
  const pct = Math.round(clamped);
  const framesDone = Math.round((clamped / 100) * TOTAL_FRAMES);
  const mbDone = ((clamped / 100) * TOTAL_MB).toFixed(1);
  const etaSec = Math.ceil(((100 - clamped) * 0.7) / speed);
  const etaText =
    clamped >= 100
      ? "00:00"
      : `${String(Math.floor(etaSec / 60)).padStart(2, "0")}:${String(etaSec % 60).padStart(2, "0")}`;

  const gpu =
    status === "running" ? Math.round(74 + 20 * Math.abs(Math.sin(clamped / 6))) : status === "done" ? 6 : 0;
  const buffer =
    status === "running" ? Math.round(46 + 48 * Math.abs(Math.sin(clamped / 4 + 1))) : status === "done" ? 100 : 0;

  const barLabel = `${pct}% · ${phaseName(clamped)}`;

  const primary =
    status === "running"
      ? ({ text: "Pause", kind: "pause" } as const)
      : status === "done" || clamped >= 100
        ? ({ text: "Replay", kind: "replay" } as const)
        : clamped > 0
          ? ({ text: "Resume", kind: "play" } as const)
          : ({ text: "Start export", kind: "play" } as const);

  function handlePrimary() {
    if (status === "running") {
      setStatus("paused");
      return;
    }
    if (status === "done" || clamped >= 100) {
      lastPhase.current = "";
      setProgress(0);
      setStatus("running");
      return;
    }
    setStatus("running");
  }

  function handleReset() {
    lastPhase.current = "";
    setProgress(0);
    setStatus("idle");
    setAnnounce("Export reset. Ready to start again.");
  }

  function handleScrub(e: ChangeEvent<HTMLInputElement>) {
    const v = Number(e.target.value);
    setProgress(v);
    setStatus(v >= 100 ? "done" : "paused");
  }

  function handleSpeedKey(e: KeyboardEvent<HTMLButtonElement>, idx: number) {
    let next = idx;
    if (e.key === "ArrowRight" || e.key === "ArrowDown") next = (idx + 1) % SPEEDS.length;
    else if (e.key === "ArrowLeft" || e.key === "ArrowUp") next = (idx - 1 + SPEEDS.length) % SPEEDS.length;
    else if (e.key === "Home") next = 0;
    else if (e.key === "End") next = SPEEDS.length - 1;
    else return;
    e.preventDefault();
    setSpeed(SPEEDS[next]);
    speedRefs.current[next]?.focus();
  }

  const statusMeta =
    status === "running"
      ? { label: "Rendering", cls: "bg-indigo-100 text-indigo-700 ring-indigo-500/20 dark:bg-indigo-500/15 dark:text-indigo-300" }
      : status === "paused"
        ? { label: "Paused", cls: "bg-amber-100 text-amber-700 ring-amber-500/20 dark:bg-amber-500/15 dark:text-amber-300" }
        : status === "done"
          ? { label: "Complete", cls: "bg-emerald-100 text-emerald-700 ring-emerald-500/20 dark:bg-emerald-500/15 dark:text-emerald-300" }
          : { label: "Ready", cls: "bg-slate-100 text-slate-600 ring-slate-500/20 dark:bg-slate-800 dark:text-slate-300" };

  const stats = [
    { icon: <IconFilm className="h-4 w-4" />, k: "Frames", v: `${framesDone.toLocaleString()} / ${TOTAL_FRAMES.toLocaleString()}` },
    { icon: <IconDrive className="h-4 w-4" />, k: "Output", v: `${mbDone} MB` },
    { icon: <IconClock className="h-4 w-4" />, k: "Time left", v: etaText },
  ];

  return (
    <section
      aria-labelledby="pli-title"
      className="relative w-full bg-gradient-to-b from-slate-50 to-white px-4 py-16 text-slate-900 sm:py-24 dark:from-slate-950 dark:to-slate-900 dark:text-slate-100"
    >
      <style>{`
        @keyframes pli-sweep {
          0% { transform: translateX(-110%); }
          100% { transform: translateX(240%); }
        }
        @keyframes pli-blink {
          0%, 100% { opacity: 1; }
          50% { opacity: 0.35; }
        }
        @media (prefers-reduced-motion: reduce) {
          .pli-sweep, .pli-blink { animation: none !important; }
        }
      `}</style>

      <div className="mx-auto max-w-2xl">
        <div className="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/30">
          {/* Header */}
          <div className="flex items-start gap-4">
            <div className="grid h-12 w-12 shrink-0 place-items-center rounded-2xl bg-gradient-to-br from-indigo-500 to-violet-600 text-white shadow-lg shadow-indigo-500/30">
              <IconClapper className="h-6 w-6" />
            </div>
            <div className="min-w-0 flex-1">
              <p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-indigo-600 dark:text-indigo-400">
                Render Queue
              </p>
              <h2 id="pli-title" className="mt-0.5 truncate text-lg font-bold tracking-tight sm:text-xl">
                Sunset_Reel_v4.mp4
              </h2>
              <p className="mt-0.5 truncate text-xs text-slate-500 dark:text-slate-400">
                3840 × 2160 · H.264 · 24 fps
              </p>
            </div>
            <span
              className={`inline-flex shrink-0 items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-semibold ring-1 ring-inset ${statusMeta.cls}`}
            >
              <span
                className={`h-1.5 w-1.5 rounded-full bg-current ${status === "running" && !reduce ? "pli-blink" : ""}`}
                style={status === "running" && !reduce ? { animation: "pli-blink 1.1s ease-in-out infinite" } : undefined}
              />
              {statusMeta.label}
            </span>
          </div>

          {/* Main progress bar with inner label */}
          <div className="mt-7">
            <div
              ref={trackRef}
              role="progressbar"
              aria-label="Video export progress"
              aria-valuemin={0}
              aria-valuemax={100}
              aria-valuenow={pct}
              aria-valuetext={`${pct} percent — ${phaseName(clamped)}`}
              className="relative h-11 w-full overflow-hidden rounded-full bg-slate-200 ring-1 ring-inset ring-slate-300/70 dark:bg-slate-800 dark:ring-slate-700/60"
            >
              <div className="absolute inset-0 flex items-center justify-center px-4">
                <span className="whitespace-nowrap text-sm font-semibold tracking-tight text-slate-600 tabular-nums dark:text-slate-300">
                  {barLabel}
                </span>
              </div>

              <motion.div
                className="absolute inset-y-0 left-0 overflow-hidden rounded-full bg-gradient-to-r from-indigo-600 via-indigo-500 to-violet-500"
                initial={false}
                animate={{ width: `${clamped}%` }}
                transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 140, damping: 24 }}
              >
                <div
                  className="absolute inset-y-0 left-0 flex items-center justify-center px-4"
                  style={{ width: trackW || undefined }}
                >
                  <span className="whitespace-nowrap text-sm font-semibold tracking-tight text-white tabular-nums">
                    {barLabel}
                  </span>
                </div>
                {status === "running" && !reduce ? (
                  <span
                    className="pli-sweep pointer-events-none absolute inset-y-0 left-0 w-24 bg-gradient-to-r from-transparent via-white/35 to-transparent"
                    style={{ animation: "pli-sweep 1.6s linear infinite" }}
                  />
                ) : null}
              </motion.div>
            </div>

            {/* Stats */}
            <dl className="mt-4 grid grid-cols-3 gap-3">
              {stats.map((s) => (
                <div
                  key={s.k}
                  className="rounded-2xl border border-slate-200 bg-slate-50 px-3 py-3 dark:border-slate-800 dark:bg-slate-800/40"
                >
                  <dt className="flex items-center gap-1.5 text-[11px] font-medium uppercase tracking-wide text-slate-500 dark:text-slate-400">
                    <span className="text-indigo-500 dark:text-indigo-400">{s.icon}</span>
                    {s.k}
                  </dt>
                  <dd className="mt-1 text-sm font-semibold tabular-nums text-slate-800 dark:text-slate-100">
                    {s.v}
                  </dd>
                </div>
              ))}
            </dl>

            {/* Secondary meters, also labelled inside */}
            <div className="mt-4 space-y-2.5">
              <StatBar label="GPU utilisation" value={gpu} tone="indigo" />
              <StatBar label="Encoder buffer" value={buffer} tone="emerald" />
            </div>
          </div>

          {/* Scrub control */}
          <div className="mt-7">
            <label
              htmlFor="pli-scrub"
              className="mb-2 block text-xs font-medium text-slate-500 dark:text-slate-400"
            >
              Scrub progress
            </label>
            <input
              id="pli-scrub"
              type="range"
              min={0}
              max={100}
              step={0.5}
              value={clamped}
              onChange={handleScrub}
              aria-label="Scrub export progress"
              className="h-2 w-full cursor-pointer appearance-none rounded-full bg-slate-200 accent-indigo-600 outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:bg-slate-700 dark:focus-visible:ring-offset-slate-900"
            />
          </div>

          {/* Controls */}
          <div className="mt-6 flex flex-wrap items-center gap-3">
            <button
              type="button"
              onClick={handlePrimary}
              className="inline-flex items-center gap-2 rounded-full bg-indigo-600 px-5 py-2.5 text-sm font-semibold text-white shadow-lg shadow-indigo-600/25 outline-none transition-colors hover:bg-indigo-500 focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900"
            >
              {primary.kind === "pause" ? (
                <IconPause className="h-4 w-4" />
              ) : primary.kind === "replay" ? (
                <IconReplay className="h-4 w-4" />
              ) : (
                <IconPlay className="h-4 w-4" />
              )}
              {primary.text}
            </button>

            <button
              type="button"
              onClick={handleReset}
              disabled={clamped === 0 && status === "idle"}
              className="inline-flex items-center gap-2 rounded-full border border-slate-300 bg-white px-4 py-2.5 text-sm font-semibold text-slate-700 outline-none transition-colors hover:bg-slate-50 focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-45 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-200 dark:hover:bg-slate-700 dark:focus-visible:ring-offset-slate-900"
            >
              <IconReset className="h-4 w-4" />
              Reset
            </button>

            <div
              role="radiogroup"
              aria-label="Render speed"
              className="ml-auto inline-flex items-center rounded-full border border-slate-300 bg-slate-100 p-1 dark:border-slate-700 dark:bg-slate-800"
            >
              {SPEEDS.map((s, i) => {
                const active = speed === s;
                return (
                  <button
                    key={s}
                    ref={(el) => {
                      speedRefs.current[i] = el;
                    }}
                    type="button"
                    role="radio"
                    aria-checked={active}
                    tabIndex={active ? 0 : -1}
                    onClick={() => setSpeed(s)}
                    onKeyDown={(e) => handleSpeedKey(e, i)}
                    className={`rounded-full px-3 py-1.5 text-xs font-semibold tabular-nums outline-none transition-colors 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-800 ${
                      active
                        ? "bg-white text-indigo-700 shadow-sm dark:bg-slate-950 dark:text-indigo-300"
                        : "text-slate-500 hover:text-slate-800 dark:text-slate-400 dark:hover:text-slate-200"
                    }`}
                  >
                    {s}×
                  </button>
                );
              })}
            </div>
          </div>

          <p aria-live="polite" className="sr-only">
            {announce}
          </p>
        </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 →