Web InnoventixFreeCode

Progress Ring Card

Original · free

A card featuring an animated circular progress ring.

byWeb InnoventixReact + Tailwind
cardprogressringcards
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/card-progress-ring.json
card-progress-ring.tsx
"use client";

import {
  useCallback,
  useEffect,
  useId,
  useRef,
  useState,
  type ReactNode,
} from "react";
import { useReducedMotion } from "motion/react";

type RingProps = {
  pct: number;
  size: number;
  stroke: number;
  from: string;
  to: string;
  reduced: boolean;
  ariaLabel: string;
  children: ReactNode;
};

function Ring({
  pct,
  size,
  stroke,
  from,
  to,
  reduced,
  ariaLabel,
  children,
}: RingProps) {
  const uid = useId().replace(/[:]/g, "");
  const gradId = `cpr-grad-${uid}`;
  const glowId = `cpr-glow-${uid}`;
  const clamped = Math.max(0, Math.min(100, pct));
  const r = (size - stroke) / 2;
  const c = 2 * Math.PI * r;
  const offset = c * (1 - clamped / 100);
  const center = size / 2;

  return (
    <div
      className="relative shrink-0"
      style={{ width: size, height: size }}
      role="progressbar"
      aria-label={ariaLabel}
      aria-valuenow={Math.round(clamped)}
      aria-valuemin={0}
      aria-valuemax={100}
    >
      <svg
        width={size}
        height={size}
        viewBox={`0 0 ${size} ${size}`}
        className="-rotate-90 overflow-visible"
        aria-hidden="true"
      >
        <defs>
          <linearGradient id={gradId} x1="0%" y1="0%" x2="100%" y2="100%">
            <stop offset="0%" stopColor={from} />
            <stop offset="100%" stopColor={to} />
          </linearGradient>
          <filter id={glowId} x="-40%" y="-40%" width="180%" height="180%">
            <feGaussianBlur stdDeviation="4" result="b" />
            <feMerge>
              <feMergeNode in="b" />
              <feMergeNode in="SourceGraphic" />
            </feMerge>
          </filter>
        </defs>
        <circle
          cx={center}
          cy={center}
          r={r}
          fill="none"
          strokeWidth={stroke}
          className="stroke-slate-200/90 dark:stroke-slate-700/70"
        />
        <circle
          cx={center}
          cy={center}
          r={r}
          fill="none"
          strokeWidth={stroke}
          strokeLinecap="round"
          stroke={`url(#${gradId})`}
          strokeDasharray={c}
          strokeDashoffset={offset}
          filter={`url(#${glowId})`}
          style={{
            transition: reduced
              ? "none"
              : "stroke-dashoffset 800ms cubic-bezier(0.22,1,0.36,1)",
          }}
        />
      </svg>
      <div className="absolute inset-0 flex flex-col items-center justify-center text-center">
        {children}
      </div>
    </div>
  );
}

function useCountUp(target: number, reduced: boolean): number {
  const [value, setValue] = useState<number>(target);
  const prev = useRef<number>(target);

  useEffect(() => {
    if (reduced) {
      prev.current = target;
      setValue(target);
      return;
    }
    const from = prev.current;
    const to = target;
    if (from === to) return;
    const duration = 800;
    const start = performance.now();
    let raf = 0;
    const tick = (now: number) => {
      const t = Math.min(1, (now - start) / duration);
      const eased = 1 - Math.pow(1 - t, 3);
      setValue(Math.round(from + (to - from) * eased));
      if (t < 1) {
        raf = requestAnimationFrame(tick);
      } else {
        prev.current = to;
      }
    };
    raf = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(raf);
  }, [target, reduced]);

  return value;
}

function CheckMark({ reduced }: { reduced: boolean }) {
  return (
    <svg
      viewBox="0 0 24 24"
      fill="none"
      className="h-6 w-6"
      style={reduced ? undefined : { animation: "cpr-pop 480ms ease-out both" }}
      aria-hidden="true"
    >
      <path
        d="M20 6 9 17l-5-5"
        stroke="currentColor"
        strokeWidth={3}
        strokeLinecap="round"
        strokeLinejoin="round"
      />
    </svg>
  );
}

function Spinner() {
  return (
    <svg viewBox="0 0 24 24" fill="none" className="h-4 w-4" aria-hidden="true">
      <circle
        cx="12"
        cy="12"
        r="9"
        stroke="currentColor"
        strokeOpacity={0.25}
        strokeWidth={3}
      />
      <path
        d="M21 12a9 9 0 0 0-9-9"
        stroke="currentColor"
        strokeWidth={3}
        strokeLinecap="round"
        style={{ animation: "cpr-spin 700ms linear infinite" }}
      />
    </svg>
  );
}

const btnBase =
  "inline-flex items-center justify-center gap-2 rounded-xl text-sm font-semibold transition-[transform,background-color,border-color,box-shadow] duration-150 active:translate-y-px 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 disabled:cursor-not-allowed disabled:opacity-50";

const cardBase =
  "relative flex flex-col rounded-3xl border border-slate-200/80 bg-white p-6 shadow-[0_1px_0_0_rgba(255,255,255,0.6)_inset,0_18px_40px_-24px_rgba(15,23,42,0.35)] dark:border-slate-800 dark:bg-slate-900 dark:shadow-[0_1px_0_0_rgba(255,255,255,0.04)_inset,0_20px_50px_-28px_rgba(0,0,0,0.9)]";

type Category = { key: string; label: string; gb: number; from: string; to: string };

const STORAGE_TOTAL = 200;
const STORAGE: Category[] = [
  { key: "photos", label: "Photos & Video", gb: 82.4, from: "#38bdf8", to: "#6366f1" },
  { key: "documents", label: "Documents", gb: 41.6, from: "#a78bfa", to: "#7c3aed" },
  { key: "backups", label: "Device Backups", gb: 23.1, from: "#34d399", to: "#0ea5e9" },
];

export default function CardProgressRing() {
  const reduced = useReducedMotion() ?? false;

  // Card 1 — Move goal
  const STEP_GOAL = 10000;
  const [steps, setSteps] = useState<number>(6250);
  const displaySteps = useCountUp(steps, reduced);
  const stepPct = Math.min(100, (steps / STEP_GOAL) * 100);
  const stepsDone = steps >= STEP_GOAL;
  const addSteps = (n: number) =>
    setSteps((s) => Math.min(STEP_GOAL + 4000, s + n));

  // Card 2 — Storage breakdown
  const [activeCat, setActiveCat] = useState<string>(STORAGE[0].key);
  const current = STORAGE.find((c) => c.key === activeCat) ?? STORAGE[0];
  const usedGb = STORAGE.reduce((a, c) => a + c.gb, 0);
  const catPct = (current.gb / STORAGE_TOTAL) * 100;
  const displayGb = useCountUp(Math.round(current.gb * 10), reduced) / 10;

  // Card 3 — Course progress
  const TOTAL_LESSONS = 12;
  const [lessons, setLessons] = useState<number>(7);
  const [loading, setLoading] = useState<boolean>(false);
  const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
  const lessonPct = (lessons / TOTAL_LESSONS) * 100;
  const displayLessonPct = useCountUp(Math.round(lessonPct), reduced);
  const courseDone = lessons >= TOTAL_LESSONS;

  const completeLesson = useCallback(() => {
    if (loading || lessons >= TOTAL_LESSONS) return;
    setLoading(true);
    timer.current = setTimeout(
      () => {
        setLessons((l) => Math.min(TOTAL_LESSONS, l + 1));
        setLoading(false);
      },
      reduced ? 0 : 650
    );
  }, [loading, lessons, reduced]);

  useEffect(() => {
    return () => {
      if (timer.current) clearTimeout(timer.current);
    };
  }, []);

  return (
    <section className="relative w-full overflow-hidden bg-slate-50 px-5 py-20 text-slate-900 sm:px-8 dark:bg-slate-950 dark:text-slate-100">
      <style>{`
        @keyframes cpr-pop {
          0% { transform: scale(0.4); opacity: 0; }
          60% { transform: scale(1.18); opacity: 1; }
          100% { transform: scale(1); opacity: 1; }
        }
        @keyframes cpr-spin { to { transform: rotate(360deg); } }
        @keyframes cpr-rise {
          from { opacity: 0; transform: translateY(14px); }
          to { opacity: 1; transform: translateY(0); }
        }
        @media (prefers-reduced-motion: reduce) {
          .cpr-anim { animation: none !important; }
        }
      `}</style>

      <div
        aria-hidden="true"
        className="pointer-events-none absolute -top-24 left-1/2 h-72 w-72 -translate-x-1/2 rounded-full bg-indigo-300/30 blur-3xl dark:bg-indigo-600/15"
      />

      <div className="relative mx-auto max-w-5xl">
        <header className="mb-12 max-w-xl">
          <span className="inline-flex items-center gap-2 rounded-full border border-slate-200 bg-white px-3 py-1 text-xs font-semibold uppercase tracking-[0.14em] text-indigo-600 dark:border-slate-800 dark:bg-slate-900 dark:text-indigo-300">
            <span className="h-1.5 w-1.5 rounded-full bg-indigo-500" />
            Progress Rings
          </span>
          <h2 className="mt-4 text-3xl font-bold tracking-tight sm:text-4xl">
            Goals worth watching fill up.
          </h2>
          <p className="mt-3 text-base leading-relaxed text-slate-600 dark:text-slate-400">
            Tactile, accessible cards with animated circular progress. Every
            button really works — add activity, switch what the ring measures,
            or complete a lesson.
          </p>
        </header>

        <div className="grid grid-cols-1 gap-6 md:grid-cols-2">
          {/* Card 1 — Move goal (wide) */}
          <article className={`${cardBase} md:col-span-2 md:flex-row md:items-center md:gap-8`}>
            <div className="flex items-center gap-6">
              <Ring
                pct={stepPct}
                size={148}
                stroke={13}
                from="#34d399"
                to="#0ea5e9"
                reduced={reduced}
                ariaLabel={`Daily steps: ${steps} of ${STEP_GOAL}`}
              >
                {stepsDone ? (
                  <span className="flex flex-col items-center text-emerald-500 dark:text-emerald-400">
                    <span className="cpr-anim">
                      <CheckMark reduced={reduced} />
                    </span>
                    <span className="mt-1 text-xs font-semibold uppercase tracking-wide">
                      Done
                    </span>
                  </span>
                ) : (
                  <>
                    <span className="text-2xl font-bold tabular-nums">
                      {displaySteps.toLocaleString()}
                    </span>
                    <span className="text-[11px] font-medium uppercase tracking-wide text-slate-500 dark:text-slate-400">
                      of {STEP_GOAL.toLocaleString()}
                    </span>
                  </>
                )}
              </Ring>

              <div className="md:hidden">
                <p className="text-sm font-semibold text-slate-500 dark:text-slate-400">
                  Today
                </p>
                <p className="text-xl font-bold">Move Ring</p>
              </div>
            </div>

            <div className="mt-6 flex-1 md:mt-0">
              <div className="hidden md:block">
                <p className="text-sm font-semibold text-slate-500 dark:text-slate-400">
                  Today · Wed 15 Jul
                </p>
                <h3 className="text-2xl font-bold tracking-tight">Move Ring</h3>
                <p className="mt-1 text-sm text-slate-600 dark:text-slate-400">
                  {stepsDone
                    ? "You hit your goal — nice work. Keep the streak alive."
                    : `${(STEP_GOAL - steps).toLocaleString()} steps to close your ring.`}
                </p>
              </div>

              <div className="mt-5 flex flex-wrap gap-2.5">
                <button
                  type="button"
                  onClick={() => addSteps(500)}
                  disabled={stepsDone}
                  className={`${btnBase} bg-slate-900 px-4 py-2.5 text-white shadow-sm hover:bg-slate-700 focus-visible:ring-slate-500 dark:bg-white dark:text-slate-900 dark:hover:bg-slate-200`}
                >
                  <PlusIcon /> 500 steps
                </button>
                <button
                  type="button"
                  onClick={() => addSteps(2000)}
                  disabled={stepsDone}
                  className={`${btnBase} border border-slate-300 bg-white px-4 py-2.5 text-slate-800 hover:border-slate-400 hover:bg-slate-50 focus-visible:ring-slate-400 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-100 dark:hover:bg-slate-700`}
                >
                  <PlusIcon /> 2,000 steps
                </button>
                <button
                  type="button"
                  onClick={() => setSteps(0)}
                  aria-label="Reset step count"
                  className={`${btnBase} border border-slate-300 bg-white px-3 py-2.5 text-slate-500 hover:border-rose-300 hover:text-rose-600 focus-visible:ring-rose-400 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-400 dark:hover:border-rose-500/60 dark:hover:text-rose-400`}
                >
                  <ResetIcon />
                </button>
              </div>
            </div>
          </article>

          {/* Card 2 — Storage breakdown */}
          <article className={cardBase}>
            <div className="flex items-center justify-between">
              <div>
                <h3 className="text-lg font-bold tracking-tight">iCloud+ Storage</h3>
                <p className="text-sm text-slate-500 dark:text-slate-400">
                  {usedGb.toFixed(1)} GB of {STORAGE_TOTAL} GB used
                </p>
              </div>
              <CloudIcon />
            </div>

            <div className="mt-4 flex justify-center">
              <Ring
                pct={catPct}
                size={168}
                stroke={14}
                from={current.from}
                to={current.to}
                reduced={reduced}
                ariaLabel={`${current.label}: ${current.gb} gigabytes of ${STORAGE_TOTAL}`}
              >
                <span className="text-[11px] font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">
                  {current.label}
                </span>
                <span className="text-3xl font-bold tabular-nums">
                  {displayGb.toFixed(1)}
                </span>
                <span className="text-xs font-medium text-slate-500 dark:text-slate-400">
                  GB · {Math.round(catPct)}%
                </span>
              </Ring>
            </div>

            <div className="mt-5 flex flex-col gap-2" role="group" aria-label="Storage categories">
              {STORAGE.map((cat) => {
                const active = cat.key === activeCat;
                return (
                  <button
                    key={cat.key}
                    type="button"
                    aria-pressed={active}
                    onClick={() => setActiveCat(cat.key)}
                    className={`${btnBase} justify-between px-3.5 py-2.5 ${
                      active
                        ? "border border-transparent bg-slate-900 text-white focus-visible:ring-slate-500 dark:bg-white dark:text-slate-900"
                        : "border border-slate-200 bg-white text-slate-600 hover:bg-slate-50 focus-visible:ring-slate-400 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-300 dark:hover:bg-slate-800"
                    }`}
                  >
                    <span className="flex items-center gap-2.5">
                      <span
                        className="h-2.5 w-2.5 rounded-full"
                        style={{
                          background: `linear-gradient(135deg, ${cat.from}, ${cat.to})`,
                        }}
                        aria-hidden="true"
                      />
                      {cat.label}
                    </span>
                    <span className="tabular-nums font-semibold">
                      {cat.gb.toFixed(1)} GB
                    </span>
                  </button>
                );
              })}
            </div>
          </article>

          {/* Card 3 — Course progress */}
          <article className={cardBase}>
            <div className="flex items-start justify-between gap-3">
              <div>
                <span className="inline-flex rounded-full bg-violet-100 px-2.5 py-1 text-[11px] font-semibold uppercase tracking-wide text-violet-700 dark:bg-violet-500/15 dark:text-violet-300">
                  In progress
                </span>
                <h3 className="mt-3 text-lg font-bold leading-snug tracking-tight">
                  Advanced TypeScript for React Engineers
                </h3>
                <p className="text-sm text-slate-500 dark:text-slate-400">
                  {lessons} of {TOTAL_LESSONS} lessons complete
                </p>
              </div>
            </div>

            <div className="mt-2 flex justify-center">
              <Ring
                pct={lessonPct}
                size={168}
                stroke={14}
                from="#a78bfa"
                to="#6366f1"
                reduced={reduced}
                ariaLabel={`Course progress: ${lessons} of ${TOTAL_LESSONS} lessons`}
              >
                {courseDone ? (
                  <span className="flex flex-col items-center text-violet-500 dark:text-violet-300">
                    <span className="cpr-anim">
                      <CheckMark reduced={reduced} />
                    </span>
                    <span className="mt-1 text-xs font-semibold uppercase tracking-wide">
                      Certified
                    </span>
                  </span>
                ) : (
                  <>
                    <span className="text-4xl font-bold tabular-nums">
                      {displayLessonPct}
                      <span className="text-xl">%</span>
                    </span>
                    <span className="text-xs font-medium text-slate-500 dark:text-slate-400">
                      {TOTAL_LESSONS - lessons} lessons left
                    </span>
                  </>
                )}
              </Ring>
            </div>

            <div className="mt-5 flex gap-2.5">
              <button
                type="button"
                onClick={completeLesson}
                disabled={courseDone || loading}
                aria-busy={loading}
                className={`${btnBase} flex-1 bg-violet-600 px-4 py-2.5 text-white shadow-sm hover:bg-violet-500 focus-visible:ring-violet-500`}
              >
                {loading ? (
                  <>
                    <Spinner /> Saving…
                  </>
                ) : courseDone ? (
                  "Course complete"
                ) : (
                  <>
                    <CheckSmallIcon /> Complete next lesson
                  </>
                )}
              </button>
              <button
                type="button"
                onClick={() => {
                  setLessons(0);
                  setLoading(false);
                  if (timer.current) clearTimeout(timer.current);
                }}
                aria-label="Reset course progress"
                className={`${btnBase} border border-slate-300 bg-white px-3 py-2.5 text-slate-500 hover:border-slate-400 hover:text-slate-800 focus-visible:ring-slate-400 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-400 dark:hover:text-slate-100`}
              >
                <ResetIcon />
              </button>
            </div>
          </article>
        </div>
      </div>
    </section>
  );
}

function PlusIcon() {
  return (
    <svg viewBox="0 0 24 24" fill="none" className="h-4 w-4" aria-hidden="true">
      <path
        d="M12 5v14M5 12h14"
        stroke="currentColor"
        strokeWidth={2.2}
        strokeLinecap="round"
      />
    </svg>
  );
}

function ResetIcon() {
  return (
    <svg viewBox="0 0 24 24" fill="none" className="h-4 w-4" aria-hidden="true">
      <path
        d="M3 12a9 9 0 1 0 3-6.7M3 4v4h4"
        stroke="currentColor"
        strokeWidth={2}
        strokeLinecap="round"
        strokeLinejoin="round"
      />
    </svg>
  );
}

function CheckSmallIcon() {
  return (
    <svg viewBox="0 0 24 24" fill="none" className="h-4 w-4" aria-hidden="true">
      <path
        d="M20 6 9 17l-5-5"
        stroke="currentColor"
        strokeWidth={2.4}
        strokeLinecap="round"
        strokeLinejoin="round"
      />
    </svg>
  );
}

function CloudIcon() {
  return (
    <svg
      viewBox="0 0 24 24"
      fill="none"
      className="h-7 w-7 text-sky-500 dark:text-sky-400"
      aria-hidden="true"
    >
      <path
        d="M7 18a4 4 0 0 1-.5-7.97 5.5 5.5 0 0 1 10.6-1.06A4.25 4.25 0 0 1 16.75 18H7Z"
        stroke="currentColor"
        strokeWidth={1.8}
        strokeLinejoin="round"
      />
    </svg>
  );
}

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 →