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.
// 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
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.
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.
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.
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.
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.
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.