Activity Rings Card with Day Switcher

A dark fitness style card with three nested progress rings for move, focus and rest goals. Each ring is an SVG circle with a gradient stroke that fills with a spring when the card loads. A row of seven day buttons switches the data, and the rings glide to the new values while the numbers beside them count up or down. A line under the title reports how many rings are closed. On phones the rings sit on top and the three figures move into a row underneath.

Code

// Adapted from Kokonut UI (https://github.com/kokonut-labs/kokonutui/blob/main/components/kokonutui/apple-activity-card.tsx)
// Copyright (c) 2025 kokonutUI. MIT License: https://we.inc/components/licenses
import { useEffect, useState } from "react";
import { animate, motion, useReducedMotion } from "framer-motion";

// One ring per goal, from the outside in. `from` and `to` are the two ends of the ring's gradient.
const goals = [
  { key: "move", label: "Move", unit: "kcal", target: 620, size: 200, from: "#fb7185", to: "#fda4af" },
  { key: "focus", label: "Focus", unit: "min", target: 240, size: 156, from: "#a3e635", to: "#d9f99d" },
  { key: "rest", label: "Rest", unit: "hr", target: 8, size: 112, from: "#38bdf8", to: "#7dd3fc" },
] as const;

// What was logged on each day, in the same order as `goals`.
const week = [
  { day: "Mon", values: [410, 150, 6] },
  { day: "Tue", values: [655, 240, 7] },
  { day: "Wed", values: [527, 204, 5] },
  { day: "Thu", values: [300, 90, 8] },
  { day: "Fri", values: [590, 228, 6] },
  { day: "Sat", values: [220, 45, 9] },
  { day: "Sun", values: [480, 120, 7] },
];

const STROKE = 16;

/** A number that counts to its new value whenever the value changes. */
function CountUp({ value, instant }: { value: number; instant: boolean }) {
  const [shown, setShown] = useState(instant ? value : 0);
  useEffect(() => {
    if (instant) {
      setShown(value);
      return;
    }
    const controls = animate(shown, value, { duration: 0.9, ease: [0.22, 1, 0.36, 1], onUpdate: (v) => setShown(Math.round(v)) });
    return () => controls.stop();
    // `shown` is read once as the starting point of each run.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [value, instant]);
  return <>{shown}</>;
}

export default function ActivityRingsCardWithDaySwitcher() {
  const [dayIndex, setDayIndex] = useState(2);
  const reduce = useReducedMotion();
  const today = week[dayIndex];
  const closed = goals.filter((goal, i) => today.values[i] >= goal.target).length;

  return (
    <div className="flex w-full items-center justify-center bg-stone-100 px-4 py-14">
      <section className="w-full max-w-xl rounded-[2rem] bg-zinc-950 p-6 text-white shadow-2xl shadow-zinc-900/30 sm:p-8" aria-labelledby="activity-rings-title">
        <div className="flex items-start justify-between gap-4">
          <div>
            <h2 id="activity-rings-title" className="text-xl font-semibold tracking-tight">
              Daily goals
            </h2>
            <p className="mt-1 text-sm text-zinc-400" aria-live="polite">
              {closed === 3 ? "All three rings closed" : `${closed} of 3 rings closed`}
            </p>
          </div>
          <span className="rounded-full bg-white/10 px-3 py-1 text-xs font-medium text-zinc-200">6 day streak</span>
        </div>

        <div className="mt-6 grid grid-cols-7 gap-1.5" role="group" aria-label="Day of the week">
          {week.map((entry, i) => (
            <button
              key={entry.day}
              type="button"
              aria-pressed={i === dayIndex}
              onClick={() => setDayIndex(i)}
              className={`h-10 rounded-xl text-xs font-semibold transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white ${i === dayIndex ? "bg-white text-zinc-950" : "bg-white/5 text-zinc-300 hover:bg-white/10"}`}
            >
              {entry.day}
            </button>
          ))}
        </div>

        <div className="mt-8 flex flex-col items-center gap-8 sm:flex-row sm:gap-10">
          <div className="relative h-[200px] w-[200px] shrink-0">
            {goals.map((goal, i) => {
              const radius = (goal.size - STROKE) / 2;
              const circumference = radius * 2 * Math.PI;
              const share = Math.min(today.values[i] / goal.target, 1);
              return (
                <motion.div
                  key={goal.key}
                  className="absolute inset-0 flex items-center justify-center"
                  initial={reduce ? false : { opacity: 0, scale: 0.8 }}
                  animate={{ opacity: 1, scale: 1 }}
                  transition={{ duration: 0.6, delay: i * 0.12, ease: "easeOut" }}
                >
                  <svg className="-rotate-90" width={goal.size} height={goal.size} viewBox={`0 0 ${goal.size} ${goal.size}`} role="img" aria-label={`${goal.label}: ${Math.round(share * 100)} percent of goal`}>
                    <defs>
                      <linearGradient id={`activity-rings-${goal.key}`} x1="0%" y1="0%" x2="100%" y2="100%">
                        <stop offset="0%" stopColor={goal.from} />
                        <stop offset="100%" stopColor={goal.to} />
                      </linearGradient>
                    </defs>
                    <circle cx={goal.size / 2} cy={goal.size / 2} r={radius} fill="none" stroke={goal.from} strokeOpacity="0.16" strokeWidth={STROKE} />
                    <motion.circle
                      cx={goal.size / 2}
                      cy={goal.size / 2}
                      r={radius}
                      fill="none"
                      stroke={`url(#activity-rings-${goal.key})`}
                      strokeWidth={STROKE}
                      strokeLinecap="round"
                      strokeDasharray={circumference}
                      initial={reduce ? false : { strokeDashoffset: circumference }}
                      animate={{ strokeDashoffset: circumference * (1 - share) }}
                      transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 45, damping: 14, delay: i * 0.12 }}
                      style={{ filter: `drop-shadow(0 0 6px ${goal.from}55)` }}
                    />
                  </svg>
                </motion.div>
              );
            })}
          </div>

          <dl className="grid w-full grid-cols-3 gap-4 sm:grid-cols-1 sm:gap-5">
            {goals.map((goal, i) => (
              <motion.div
                key={goal.key}
                initial={reduce ? false : { opacity: 0, x: 20 }}
                animate={{ opacity: 1, x: 0 }}
                transition={{ duration: 0.5, delay: 0.2 + i * 0.1 }}
              >
                <dt className="flex items-center gap-2 text-xs font-medium uppercase tracking-wider text-zinc-400">
                  <span className="h-2 w-2 rounded-full" style={{ background: goal.from }} aria-hidden="true" />
                  {goal.label}
                </dt>
                <dd className="mt-1 text-lg font-semibold tabular-nums sm:text-2xl" style={{ color: goal.from }}>
                  <CountUp value={today.values[i]} instant={!!reduce} />
                  <span className="text-zinc-400">/{goal.target}</span>
                  <span className="block text-xs font-medium text-zinc-400 sm:ml-1 sm:inline sm:text-sm">{goal.unit}</span>
                </dd>
              </motion.div>
            ))}
          </dl>
        </div>
      </section>
    </div>
  );
}

Install with one command: npx shadcn@latest add https://we.inc/r/activity-rings-card-with-day-switcher.json

When to use it

How to customize it

Accessibility

Questions

How is a progress ring drawn in SVG?

A circle gets a `strokeDasharray` equal to its circumference, which makes the stroke one long dash. `strokeDashoffset` then hides part of it. An offset equal to the circumference shows nothing and zero shows the full ring. The SVG is rotated a quarter turn so the ring starts at the top.

How do the rings animate when the day changes?

The offset is in the circle's `animate` prop. When the selected day changes, the target offset changes and the spring carries the ring from where it is to the new value, forwards or backwards.

What happens when a value is over the target?

The ring is capped at full. The text still shows the real number, for example 655 of 620, and the card counts that ring as closed.

Can I add a fourth ring?

Yes. Add an entry to `goals` with a smaller `size`, such as 68, and add a fourth number to every day in `week`. Below about 60 pixels a ring is hard to read.

How do the numbers count up?

A small component runs a timed animation from the number on screen to the new value and rounds it on every frame. It uses tabular figures so the digits do not shift sideways.

Adapted from Apple Activity Card in Kokonut UI, used under its open-source licence. Licences and credits

Related: Stats Section with Radial Progress Rings, Stat Tiles with Sparkline Trends, Habit Tracker Phone Screen with Tab Bar.

More animated cards or all free UI components. Start for free to have a full site built around it.

More in Animated Cards

We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.

Product

Who It's For

Features

Resources

Company

View Sitemap