Web InnoventixFreeCode

Ring Gradient Progress

Original · free

gradient circular progress

byWeb InnoventixReact + Tailwind
progringgradientprogress
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-ring-gradient.json
prog-ring-gradient.tsx
"use client";

import { useEffect, useState } from "react";
import { motion, useReducedMotion, useSpring, useTransform } from "motion/react";

type Phase = { pct: number; label: string; note: string };

const PHASES: Phase[] = [
  { pct: 0, label: "Kickoff", note: "Scope locked, team briefed" },
  { pct: 25, label: "Design", note: "Flows & UI signed off" },
  { pct: 50, label: "Build", note: "Core features in code review" },
  { pct: 75, label: "Review", note: "QA sweep & stakeholder demo" },
  { pct: 100, label: "Launch", note: "Shipped to production" },
];

const SIZE = 220;
const STROKE = 16;
const R = (SIZE - STROKE) / 2 - 6;
const CENTER = SIZE / 2;
const C = 2 * Math.PI * R;

function clamp(n: number): number {
  return Math.max(0, Math.min(100, Math.round(n)));
}

function dotAt(pct: number): { x: number; y: number } {
  const angle = ((pct / 100) * 360 - 90) * (Math.PI / 180);
  return { x: CENTER + R * Math.cos(angle), y: CENTER + R * Math.sin(angle) };
}

export default function ProgRingGradient() {
  const reduce = useReducedMotion();
  const [value, setValue] = useState<number>(68);
  const [display, setDisplay] = useState<number>(68);

  const spring = useSpring(68, { stiffness: 150, damping: 26, mass: 0.7 });
  const dashOffset = useTransform(spring, (v: number) => C * (1 - v / 100));

  useEffect(() => {
    if (reduce) spring.jump(value);
    else spring.set(value);
  }, [value, reduce, spring]);

  useEffect(() => {
    const unsub = spring.on("change", (v: number) => setDisplay(Math.round(v)));
    return () => unsub();
  }, [spring]);

  const phaseIndex = PHASES.reduce(
    (acc, p, i) => (value >= p.pct ? i : acc),
    0
  );
  const current = PHASES[phaseIndex];
  const complete = value >= 100;
  const remaining = 100 - value;

  const step = (delta: number) => setValue((v) => clamp(v + delta));

  return (
    <section className="relative w-full overflow-hidden bg-gradient-to-b from-slate-50 to-white px-6 py-20 text-slate-900 dark:from-slate-950 dark:to-slate-900 dark:text-slate-100 sm:py-24">
      <style>{`
        @keyframes prgRingHalo {
          0%   { transform: rotate(0deg); opacity: 0.55; }
          50%  { opacity: 0.9; }
          100% { transform: rotate(360deg); opacity: 0.55; }
        }
        @keyframes prgRingPop {
          0%   { transform: scale(0.6); opacity: 0; }
          60%  { transform: scale(1.12); opacity: 1; }
          100% { transform: scale(1); opacity: 1; }
        }
        .prg-halo { animation: prgRingHalo 14s linear infinite; transform-origin: center; }
        .prg-pop  { animation: prgRingPop 420ms cubic-bezier(0.22, 1, 0.36, 1) both; }
        @media (prefers-reduced-motion: reduce) {
          .prg-halo, .prg-pop { animation: none !important; }
        }
      `}</style>

      <motion.div
        initial={reduce ? false : { opacity: 0, y: 18 }}
        whileInView={reduce ? undefined : { opacity: 1, y: 0 }}
        viewport={{ once: true, amount: 0.3 }}
        transition={{ duration: 0.5, ease: [0.22, 1, 0.36, 1] }}
        className="mx-auto w-full max-w-xl rounded-3xl border border-slate-200 bg-white/80 p-7 shadow-xl shadow-slate-900/5 backdrop-blur dark:border-slate-800 dark:bg-slate-900/70 dark:shadow-black/30 sm:p-9"
      >
        <header className="mb-8">
          <span className="inline-flex items-center gap-2 rounded-full border border-indigo-200 bg-indigo-50 px-3 py-1 text-xs font-semibold uppercase tracking-wide text-indigo-700 dark:border-indigo-500/30 dark:bg-indigo-500/10 dark:text-indigo-300">
            <span className="h-1.5 w-1.5 rounded-full bg-indigo-500" />
            Release pipeline
          </span>
          <h2 className="mt-4 text-2xl font-bold tracking-tight sm:text-3xl">
            Launch readiness
          </h2>
          <p className="mt-1.5 text-sm text-slate-500 dark:text-slate-400">
            Track the Q3 mobile release from kickoff to ship.
          </p>
        </header>

        {/* Ring */}
        <div className="flex flex-col items-center">
          <div
            className="relative"
            style={{ width: SIZE, height: SIZE, maxWidth: "100%" }}
          >
            <svg
              viewBox={`0 0 ${SIZE} ${SIZE}`}
              className="h-full w-full -rotate-90"
              role="progressbar"
              aria-valuenow={value}
              aria-valuemin={0}
              aria-valuemax={100}
              aria-valuetext={`${value}% complete — phase ${current.label}`}
            >
              <defs>
                <linearGradient
                  id="prgRingGrad"
                  gradientUnits="userSpaceOnUse"
                  x1="0"
                  y1="0"
                  x2={SIZE}
                  y2={SIZE}
                >
                  <stop offset="0%" stopColor="#6366f1" />
                  <stop offset="55%" stopColor="#8b5cf6" />
                  <stop offset="100%" stopColor="#38bdf8" />
                </linearGradient>
              </defs>

              {/* rotating halo */}
              <g className="prg-halo" style={{ opacity: 0.5 }}>
                <circle
                  cx={CENTER}
                  cy={CENTER}
                  r={R + STROKE / 2 + 3}
                  fill="none"
                  stroke="url(#prgRingGrad)"
                  strokeWidth={2}
                  strokeDasharray="2 10"
                  strokeLinecap="round"
                />
              </g>

              {/* track */}
              <circle
                cx={CENTER}
                cy={CENTER}
                r={R}
                fill="none"
                strokeWidth={STROKE}
                className="stroke-slate-200 dark:stroke-slate-800"
              />

              {/* progress */}
              <motion.circle
                cx={CENTER}
                cy={CENTER}
                r={R}
                fill="none"
                stroke="url(#prgRingGrad)"
                strokeWidth={STROKE}
                strokeLinecap="round"
                strokeDasharray={C}
                style={{ strokeDashoffset: dashOffset }}
              />

              {/* milestone markers */}
              {PHASES.map((p) => {
                const { x, y } = dotAt(p.pct);
                const reached = value >= p.pct;
                return (
                  <circle
                    key={p.pct}
                    cx={x}
                    cy={y}
                    r={reached ? 5 : 4}
                    className={
                      reached
                        ? "fill-white stroke-emerald-500 dark:fill-slate-950"
                        : "fill-slate-100 stroke-slate-300 dark:fill-slate-900 dark:stroke-slate-700"
                    }
                    strokeWidth={2.5}
                  />
                );
              })}
            </svg>

            {/* center readout */}
            <div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center">
              <div className="flex items-start">
                <span className="text-5xl font-bold tabular-nums tracking-tight sm:text-6xl">
                  {display}
                </span>
                <span className="mt-1.5 text-2xl font-semibold text-slate-400 dark:text-slate-500">
                  %
                </span>
              </div>
              <div className="mt-1 flex items-center gap-1.5">
                {complete ? (
                  <span
                    key="done"
                    className="prg-pop inline-flex items-center gap-1.5 rounded-full bg-emerald-500/10 px-2.5 py-1 text-sm font-semibold text-emerald-600 dark:text-emerald-400"
                  >
                    <svg
                      viewBox="0 0 24 24"
                      className="h-4 w-4"
                      fill="none"
                      stroke="currentColor"
                      strokeWidth={2.5}
                      strokeLinecap="round"
                      strokeLinejoin="round"
                      aria-hidden="true"
                    >
                      <path d="M20 6 9 17l-5-5" />
                    </svg>
                    {current.label}
                  </span>
                ) : (
                  <span className="text-sm font-semibold text-slate-600 dark:text-slate-300">
                    {current.label}
                  </span>
                )}
              </div>
            </div>
          </div>

          <p
            aria-live="polite"
            className="mt-4 text-center text-sm text-slate-500 dark:text-slate-400"
          >
            {complete ? current.note : `${current.note} · ${remaining}% to launch`}
          </p>
        </div>

        {/* Slider + steppers */}
        <div className="mt-8">
          <div className="mb-2 flex items-center justify-between">
            <label
              htmlFor="prgRingSlider"
              className="text-sm font-medium text-slate-700 dark:text-slate-200"
            >
              Adjust completion
            </label>
            <span className="text-sm font-semibold tabular-nums text-slate-500 dark:text-slate-400">
              {value}%
            </span>
          </div>
          <div className="flex items-center gap-3">
            <button
              type="button"
              onClick={() => step(-1)}
              disabled={value <= 0}
              aria-label="Decrease completion by one percent"
              className="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-full border border-slate-300 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 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-40 dark:border-slate-700 dark:text-slate-200 dark:hover:bg-slate-800 dark:focus-visible:ring-offset-slate-900"
            >
              <svg
                viewBox="0 0 24 24"
                className="h-5 w-5"
                fill="none"
                stroke="currentColor"
                strokeWidth={2.5}
                strokeLinecap="round"
                aria-hidden="true"
              >
                <path d="M5 12h14" />
              </svg>
            </button>

            <input
              id="prgRingSlider"
              type="range"
              min={0}
              max={100}
              step={1}
              value={value}
              onChange={(e) => setValue(clamp(Number(e.target.value)))}
              className="h-2 w-full cursor-pointer appearance-none rounded-full bg-slate-200 accent-indigo-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:bg-slate-800 dark:accent-violet-400 dark:focus-visible:ring-offset-slate-900"
            />

            <button
              type="button"
              onClick={() => step(1)}
              disabled={value >= 100}
              aria-label="Increase completion by one percent"
              className="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-full border border-slate-300 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 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-40 dark:border-slate-700 dark:text-slate-200 dark:hover:bg-slate-800 dark:focus-visible:ring-offset-slate-900"
            >
              <svg
                viewBox="0 0 24 24"
                className="h-5 w-5"
                fill="none"
                stroke="currentColor"
                strokeWidth={2.5}
                strokeLinecap="round"
                aria-hidden="true"
              >
                <path d="M12 5v14M5 12h14" />
              </svg>
            </button>
          </div>
        </div>

        {/* Phase presets */}
        <div
          role="group"
          aria-label="Jump to phase"
          className="mt-6 grid grid-cols-2 gap-2 sm:grid-cols-5"
        >
          {PHASES.map((p, i) => {
            const active = i === phaseIndex;
            return (
              <button
                key={p.pct}
                type="button"
                onClick={() => setValue(p.pct)}
                aria-pressed={active}
                className={
                  "rounded-xl border px-2 py-2.5 text-center text-xs font-semibold transition focus-visible:outline-none 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 " +
                  (active
                    ? "border-transparent bg-gradient-to-br from-indigo-500 via-violet-500 to-sky-500 text-white shadow-md shadow-indigo-500/20"
                    : "border-slate-200 bg-white text-slate-600 hover:border-slate-300 hover:bg-slate-50 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-300 dark:hover:bg-slate-800")
                }
              >
                <span className="block tabular-nums">{p.pct}%</span>
                <span className="mt-0.5 block text-[11px] font-medium opacity-90">
                  {p.label}
                </span>
              </button>
            );
          })}
        </div>
      </motion.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 →