Skip to content
All Taste components

Animated screens

Fitness rings

A day of activity where three goal rings fill in turn and the numbers count up beside them.

rings fill in sequence · count-up numbers · flickering streak flame

Friday, 16 Oct

Today

KN
Move420 / 600 kcal
Exercise24 / 30 min
Stand9 / 12 hrs
12-day streak

Best so far: 21 days

MTWTFSS

Steps

7,842

78% of 10,000

Distance

5.6km

↑ 0.8 km vs avg

Evening run

Today, 6:10 pm · 5.2 km · 31 min

/* globals.css
@keyframes ring-fill { from { stroke-dashoffset: var(--v); } }
@keyframes flicker {
  0% { transform: scale(1) rotate(0); }
  30% { transform: scale(1.04, .97) rotate(-2deg); }
  60% { transform: scale(.98, 1.04) rotate(1.5deg); }
  100% { transform: scale(1.03, .99) rotate(-1deg); }
}
@keyframes ember { from { opacity: .2; } to { opacity: .5; } }
*/
"use client";

import { useEffect, useState } from "react";
import { ChartColumn, ChevronRight, Dumbbell, Flame, Footprints, MapPin, User } from "lucide-react";
import { cn } from "@/lib/utils";

const rings = [
  { label: "Move", value: 420, goal: 600, unit: "kcal", r: 54, color: "text-primary", delay: 300 },
  { label: "Exercise", value: 24, goal: 30, unit: "min", r: 39, color: "text-success", delay: 850 },
  { label: "Stand", value: 9, goal: 12, unit: "hrs", r: 24, color: "text-info", delay: 1400 },
];
const week = ["M", "T", "W", "T", "F", "S", "S"];
const tabs = [
  { label: "Today", icon: Flame, current: true },
  { label: "Workouts", icon: Dumbbell },
  { label: "Progress", icon: ChartColumn },
  { label: "Profile", icon: User },
];

/** Counts from 0 to `to` once, easing out; jumps straight there with reduced motion. */
function useCountUp(to: number, delay = 0, ms = 1300) {
  const [n, setN] = useState(0);
  useEffect(() => {
    if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) { setN(to); return; }
    let raf = 0;
    const start = performance.now() + delay;
    const tick = (t: number) => {
      const k = Math.min(1, Math.max(0, (t - start) / ms));
      setN(Math.round(to * (1 - Math.pow(1 - k, 3))));
      if (k < 1) raf = requestAnimationFrame(tick);
    };
    raf = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(raf);
  }, [to, delay, ms]);
  return n;
}

function Ring({ r, value, goal, color, delay }: (typeof rings)[number]) {
  const pct = Math.min(100, (value / goal) * 100);
  return (
    <g className={color} transform="rotate(-90 62 62)">
      <circle cx="62" cy="62" r={r} fill="none" stroke="currentColor" strokeWidth="12" opacity="0.16" />
      <circle cx="62" cy="62" r={r} fill="none" stroke="currentColor" strokeWidth="12" strokeLinecap="round" pathLength={100}
        strokeDasharray={pct + " 100"} className="animate-[ring-fill_1.3s_cubic-bezier(.2,.7,.1,1)_both] motion-reduce:animate-none"
        style={{ ["--v" as string]: pct, animationDelay: delay + "ms" }} />
    </g>
  );
}

function Legend({ label, value, goal, unit, color, delay }: (typeof rings)[number]) {
  const n = useCountUp(value, delay);
  return (
    <div>
      <dt className="flex items-center gap-1.5 text-xs text-fg-muted"><i className={cn("size-2 rounded-full bg-current", color)} />{label}</dt>
      <dd className="text-[15px] font-semibold tabular-nums">{n}<span className="text-xs font-normal text-fg-muted"> / {goal} {unit}</span></dd>
    </div>
  );
}

export default function ActivityRingsScreen() {
  const steps = useCountUp(7842, 400);
  return (
    <div className="mx-auto flex min-h-dvh max-w-md flex-col bg-bg font-sans text-fg">
      <main className="flex-1 px-4 pb-4">
        <header className="flex h-[60px] items-center">
          <div className="flex-1"><p className="text-xs text-fg-muted">Friday, 16 Oct</p><h1 className="text-2xl font-semibold tracking-tight">Today</h1></div>
          <span className="grid size-9 place-items-center rounded-full bg-primary-soft text-[13px] font-medium text-primary-soft-fg">KN</span>
        </header>

        <section aria-label="Activity rings" className="mt-1.5 flex items-center gap-3.5 rounded-lg border border-border p-3">
          <svg viewBox="0 0 124 124" className="size-32 shrink-0" role="img" aria-label="Move 70%, exercise 80%, stand 75%">
            {rings.map((r) => <Ring key={r.label} {...r} />)}
          </svg>
          <dl className="grid flex-1 gap-2">{rings.map((r) => <Legend key={r.label} {...r} />)}</dl>
        </section>

        <section aria-label="Streak" className="mt-3 flex items-center gap-3 rounded-lg bg-warning-soft p-3">
          <span aria-hidden className="grid size-11 shrink-0 place-items-center rounded-full bg-bg text-warning">
            <Flame className="size-[26px] origin-[50%_90%] animate-[flicker_2.4s_cubic-bezier(.2,.7,.1,1)_infinite_alternate] motion-reduce:animate-none" />
          </span>
          <div className="flex-1">
            <p className="text-sm font-semibold">12-day streak</p>
            <p className="text-xs text-fg-muted">Best so far: 21 days</p>
            <div className="mt-1.5 flex gap-1" aria-label="Goal met Monday to Friday">
              {week.map((d, i) => (
                <span key={i} className={cn("grid size-[22px] place-items-center rounded-full text-[10px] font-semibold", i < 5 ? "bg-warning text-bg" : "bg-bg text-fg-subtle")}>{d}</span>
              ))}
            </div>
          </div>
        </section>

        <div className="mt-3 grid grid-cols-2 gap-2">
          <div className="rounded-lg bg-surface px-3 py-2.5">
            <p className="flex items-center gap-1.5 text-xs text-fg-muted"><Footprints className="size-3.5" aria-hidden />Steps</p>
            <p className="mt-1 text-lg font-semibold tabular-nums">{steps.toLocaleString("en-IN")}</p>
            <p className="text-xs text-fg-muted">78% of 10,000</p>
          </div>
          <div className="rounded-lg bg-surface px-3 py-2.5">
            <p className="flex items-center gap-1.5 text-xs text-fg-muted"><MapPin className="size-3.5" aria-hidden />Distance</p>
            <p className="mt-1 text-lg font-semibold tabular-nums">5.6<span className="ml-0.5 text-xs font-medium text-fg-muted">km</span></p>
            <p className="text-xs text-fg-muted">↑ 0.8 km vs avg</p>
          </div>
        </div>

        <a href="#" className="mt-3 flex items-center gap-3 rounded-lg border border-border px-3 py-2.5">
          <span className="grid size-9 place-items-center rounded-full bg-primary-soft text-primary-soft-fg"><Footprints className="size-[18px]" aria-hidden /></span>
          <span className="min-w-0 flex-1"><span className="block text-sm font-semibold">Evening run</span><span className="text-xs tabular-nums text-fg-muted">Today, 6:10 pm · 5.2 km · 31 min</span></span>
          <ChevronRight className="size-4 text-fg-subtle" aria-hidden />
        </a>
      </main>

      <nav aria-label="Main" className="sticky bottom-0 grid grid-cols-4 border-t border-border bg-bg px-1.5 pb-[max(env(safe-area-inset-bottom),8px)] pt-1">
        {tabs.map(({ label, icon: Icon, current }) => (
          <a key={label} href="#" aria-current={current ? "page" : undefined}
            className={cn("flex min-h-11 flex-col items-center justify-center gap-0.5 text-[10.5px] font-medium", current ? "text-primary" : "text-fg-subtle")}>
            <Icon className="size-[22px]" aria-hidden />{label}
          </a>
        ))}
      </nav>
    </div>
  );
}

The code uses the Taste Tailwind preset. Make yours on the Taste page with Export my system.