Web InnoventixFreeCode

Ring Progress

Original · free

circular progress ring with percent

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

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

const GOAL_HOURS = 30;
const STEP = 0.5;
const RADIUS = 96;
const CIRC = 2 * Math.PI * RADIUS;
const TAU = Math.PI * 2;

type Preset = { label: string; hint: string; hours: number };

const PRESETS: Preset[] = [
  { label: "Reset", hint: "0h", hours: 0 },
  { label: "Mid-week", hint: "15h", hours: 15 },
  { label: "Goal met", hint: "30h", hours: 30 },
];

type Tier = { label: string; dot: string };

function tierFor(pct: number): Tier {
  if (pct <= 0) return { label: "No focus logged yet", dot: "bg-slate-300 dark:bg-slate-600" };
  if (pct < 34) return { label: "Warming up", dot: "bg-amber-400" };
  if (pct < 67) return { label: "Building momentum", dot: "bg-sky-400" };
  if (pct < 100) return { label: "In the zone", dot: "bg-indigo-500" };
  return { label: "Weekly goal complete", dot: "bg-emerald-500" };
}

function clampHours(v: number): number {
  if (Number.isNaN(v)) return 0;
  return Math.min(GOAL_HOURS, Math.max(0, v));
}

export default function ProgressRing() {
  const reduce = useReducedMotion();
  const rawId = useId();
  const gid = `pring-${rawId.replace(/:/g, "")}`;
  const sliderId = `${gid}-slider`;

  const [hours, setHours] = useState<number>(18);
  const [display, setDisplay] = useState<number>(0);

  const pct = (hours / GOAL_HOURS) * 100;
  const roundedTarget = Math.round(pct);
  const complete = hours >= GOAL_HOURS;
  const tier = tierFor(pct);
  const remaining = GOAL_HOURS - hours;

  const target = useMotionValue(0);
  const spring = useSpring(target, { stiffness: 120, damping: 20, mass: 0.7 });
  const live = reduce ? target : spring;

  const dashOffset = useTransform(live, (v) => {
    const f = Math.min(100, Math.max(0, v)) / 100;
    return CIRC * (1 - f);
  });
  const tipX = useTransform(live, (v) => 120 + RADIUS * Math.sin((Math.min(100, Math.max(0, v)) / 100) * TAU));
  const tipY = useTransform(live, (v) => 120 - RADIUS * Math.cos((Math.min(100, Math.max(0, v)) / 100) * TAU));

  useMotionValueEvent(live, "change", (v) => {
    setDisplay(Math.round(v));
  });

  useEffect(() => {
    target.set(pct);
  }, [pct, target]);

  function step(delta: number): void {
    setHours((h) => clampHours(Math.round((h + delta) * 2) / 2));
  }

  return (
    <section className="relative w-full overflow-hidden bg-slate-50 px-4 py-16 text-slate-900 sm:py-24 dark:bg-slate-950 dark:text-slate-100">
      <style>{`
        @keyframes prog-ring-aura {
          0%, 100% { opacity: .35; transform: scale(1); }
          50% { opacity: .7; transform: scale(1.04); }
        }
        @keyframes prog-ring-pop {
          0% { transform: scale(1); }
          45% { transform: scale(1.06); }
          100% { transform: scale(1); }
        }
        .prog-ring-aura { animation: prog-ring-aura 4s ease-in-out infinite; }
        .prog-ring-pop { animation: prog-ring-pop .6s cubic-bezier(.22,1,.36,1); }
        @media (prefers-reduced-motion: reduce) {
          .prog-ring-aura, .prog-ring-pop { animation: none !important; }
        }
      `}</style>

      <div
        aria-hidden
        className="pointer-events-none absolute -top-24 left-1/2 h-72 w-72 -translate-x-1/2 rounded-full bg-indigo-500/10 blur-3xl"
      />

      <div className="relative mx-auto max-w-3xl">
        <header className="mb-10 text-center">
          <span className="inline-flex items-center gap-2 rounded-full border border-slate-200 bg-white px-3 py-1 text-xs font-medium uppercase tracking-[0.18em] text-slate-500 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-400">
            <span aria-hidden className="h-1.5 w-1.5 rounded-full bg-indigo-500" />
            Focus tracker
          </span>
          <h2 className="mt-4 text-3xl font-semibold tracking-tight sm:text-4xl">Deep work — this week</h2>
          <p className="mx-auto mt-3 max-w-md text-sm leading-relaxed text-slate-500 dark:text-slate-400">
            Drag the dial or nudge the counter to log the focus hours you&apos;ve banked toward a 30-hour weekly target.
          </p>
        </header>

        <div className="grid items-center gap-10 rounded-3xl border border-slate-200 bg-white p-6 shadow-sm sm:p-10 md:grid-cols-2 dark:border-slate-800 dark:bg-slate-900">
          {/* Ring */}
          <div className="relative mx-auto flex aspect-square w-56 items-center justify-center sm:w-64">
            <div
              aria-hidden
              className="prog-ring-aura absolute inset-4 rounded-full bg-gradient-to-tr from-indigo-500/25 via-violet-500/25 to-sky-400/25 blur-2xl"
            />
            <svg
              viewBox="0 0 240 240"
              className="relative h-full w-full"
              role="img"
              aria-label={`Progress ring showing ${roundedTarget}% of the weekly focus goal`}
            >
              <defs>
                <linearGradient id={gid} x1="0" y1="0" x2="1" y2="1">
                  <stop offset="0%" stopColor="#6366f1" />
                  <stop offset="55%" stopColor="#8b5cf6" />
                  <stop offset="100%" stopColor="#38bdf8" />
                </linearGradient>
              </defs>
              <circle
                cx={120}
                cy={120}
                r={RADIUS}
                fill="none"
                strokeWidth={16}
                stroke="currentColor"
                className="text-slate-200 dark:text-slate-800"
              />
              <motion.circle
                cx={120}
                cy={120}
                r={RADIUS}
                fill="none"
                strokeWidth={16}
                strokeLinecap="round"
                stroke={`url(#${gid})`}
                strokeDasharray={CIRC}
                strokeDashoffset={dashOffset}
                transform="rotate(-90 120 120)"
              />
              <motion.circle
                cx={tipX}
                cy={tipY}
                r={7}
                strokeWidth={4}
                stroke={`url(#${gid})`}
                className="fill-white dark:fill-slate-950"
              />
            </svg>

            <div className="absolute inset-0 flex flex-col items-center justify-center text-center">
              <div className={complete ? "prog-ring-pop" : undefined}>
                <span className="text-5xl font-semibold tabular-nums tracking-tight text-slate-900 dark:text-white">
                  {display}
                  <span className="align-top text-2xl font-medium text-slate-400 dark:text-slate-500">%</span>
                </span>
              </div>
              <p className="mt-1 text-sm font-medium tabular-nums text-slate-500 dark:text-slate-400">
                {hours.toFixed(1)} / {GOAL_HOURS} hrs
              </p>
            </div>
          </div>

          {/* Controls */}
          <div>
            <div className="inline-flex items-center gap-2 rounded-full bg-slate-100 px-3 py-1.5 text-sm font-medium text-slate-700 dark:bg-slate-800 dark:text-slate-200">
              <span aria-hidden className={`h-2 w-2 rounded-full ${tier.dot}`} />
              {tier.label}
            </div>

            <div className="mt-6">
              <label
                htmlFor={sliderId}
                className="flex items-baseline justify-between text-sm font-medium text-slate-600 dark:text-slate-300"
              >
                <span>Focus hours logged</span>
                <span className="tabular-nums text-slate-400 dark:text-slate-500">{hours.toFixed(1)}h</span>
              </label>
              <input
                id={sliderId}
                type="range"
                min={0}
                max={GOAL_HOURS}
                step={STEP}
                value={hours}
                onChange={(e) => setHours(clampHours(parseFloat(e.target.value)))}
                aria-valuetext={`${hours.toFixed(1)} of ${GOAL_HOURS} hours`}
                className="mt-3 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-700 dark:focus-visible:ring-offset-slate-900"
              />
            </div>

            <div className="mt-6 flex items-center gap-3">
              <button
                type="button"
                onClick={() => step(-STEP)}
                disabled={hours <= 0}
                aria-label="Log 30 fewer minutes"
                className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full border border-slate-200 bg-white text-slate-700 transition hover:border-slate-300 hover:bg-slate-50 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:bg-slate-800 dark:text-slate-200 dark:hover:bg-slate-700 dark:focus-visible:ring-offset-slate-900"
              >
                <svg viewBox="0 0 20 20" className="h-5 w-5" fill="none" stroke="currentColor" strokeWidth={2} aria-hidden>
                  <path d="M5 10h10" strokeLinecap="round" />
                </svg>
              </button>

              <div className="flex-1 rounded-xl border border-slate-200 bg-slate-50 py-2 text-center dark:border-slate-700 dark:bg-slate-800/60">
                <div className="text-xs uppercase tracking-wide text-slate-400 dark:text-slate-500">Logged</div>
                <div className="text-lg font-semibold tabular-nums text-slate-900 dark:text-white">{hours.toFixed(1)}h</div>
              </div>

              <button
                type="button"
                onClick={() => step(STEP)}
                disabled={hours >= GOAL_HOURS}
                aria-label="Log 30 more minutes"
                className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full border border-slate-200 bg-white text-slate-700 transition hover:border-slate-300 hover:bg-slate-50 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:bg-slate-800 dark:text-slate-200 dark:hover:bg-slate-700 dark:focus-visible:ring-offset-slate-900"
              >
                <svg viewBox="0 0 20 20" className="h-5 w-5" fill="none" stroke="currentColor" strokeWidth={2} aria-hidden>
                  <path d="M10 5v10M5 10h10" strokeLinecap="round" />
                </svg>
              </button>
            </div>

            <div className="mt-6">
              <div className="mb-2 text-xs font-medium uppercase tracking-wide text-slate-400 dark:text-slate-500">
                Quick set
              </div>
              <div className="flex flex-wrap gap-2">
                {PRESETS.map((p) => {
                  const active = hours === p.hours;
                  return (
                    <button
                      key={p.label}
                      type="button"
                      onClick={() => setHours(p.hours)}
                      aria-pressed={active}
                      className={
                        "rounded-full px-3.5 py-1.5 text-sm font-medium 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
                          ? "bg-indigo-600 text-white shadow-sm"
                          : "border border-slate-200 bg-white text-slate-600 hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-700")
                      }
                    >
                      {p.label}
                      <span className={active ? "ml-1.5 text-indigo-200" : "ml-1.5 text-slate-400 dark:text-slate-500"}>
                        {p.hint}
                      </span>
                    </button>
                  );
                })}
              </div>
            </div>

            <p className="mt-6 flex items-center gap-2 text-sm text-slate-500 dark:text-slate-400">
              {complete ? (
                <>
                  <svg viewBox="0 0 20 20" className="h-4 w-4 text-emerald-500" fill="currentColor" aria-hidden>
                    <path
                      fillRule="evenodd"
                      d="M16.7 5.3a1 1 0 0 1 0 1.4l-7.5 7.5a1 1 0 0 1-1.4 0L3.3 10.7a1 1 0 1 1 1.4-1.4l3.1 3.1 6.8-6.8a1 1 0 0 1 1.4 0Z"
                      clipRule="evenodd"
                    />
                  </svg>
                  Goal reached — nice work this week.
                </>
              ) : (
                <>
                  <span aria-hidden className="h-4 w-px bg-slate-300 dark:bg-slate-600" />
                  <span className="tabular-nums font-medium text-slate-700 dark:text-slate-200">{remaining.toFixed(1)}h</span>
                  left to hit your weekly target.
                </>
              )}
            </p>
          </div>
        </div>

        <p aria-live="polite" className="sr-only">
          {roundedTarget} percent of weekly focus goal reached, {hours.toFixed(1)} of {GOAL_HOURS} hours logged.
        </p>
      </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 →