Skip to content

Activity Rings

Three concentric rings for move, exercise and stand.

Installation

CLI

npx shadcn@latest add https://hantui-theta.vercel.app/r/activity-rings.json

Manual

  1. Install the dependencies

    npm install clsx motion tailwind-merge
  2. Add the shared helpers

    Components use cn() from lib/utils.ts and the motion tokens below. See Installation if you have not set them up yet.

    lib/motion.ts
    import type { Transition, Variants } from "motion/react";
    
    /** Durations in seconds. */
    export const duration = {
      instant: 0.1,
      quick: 0.18,
      base: 0.28,
      slow: 0.45,
    } as const;
    
    /** Cubic-bezier curves. */
    export const ease = {
      /** Entering, UI response. */
      out: [0.22, 1, 0.36, 1],
      /** Moving on screen. */
      inOut: [0.65, 0, 0.35, 1],
      /** Sheets and the mobile menu. */
      drawer: [0.32, 0.72, 0, 1],
    } as const;
    
    /** Everyday UI: presses, icon swaps, popovers and modals. Exits run at `exitFactor` of the enter. */
    export const ui = {
      press: { scale: 0.96, duration: 0.12 },
      icon: { type: "spring", duration: 0.3, bounce: 0 },
      popover: { duration: 0.18, ease: ease.out },
      modal: { duration: 0.24, ease: ease.out },
      exitFactor: 0.6,
    } as const;
    
    /** Once-per-visit motion on the landing page: hero letters, the reel and the afterimage. */
    export const kinetic = {
      heroLetter: { duration: 0.6, ease: ease.out },
      /** Delay per letter, eased so the wave slows toward the end. */
      heroStep: 0.035,
      reelSwap: { type: "spring", duration: 0.5, bounce: 0.1 },
      /** Two afterimages: lag in seconds and opacity of each. */
      ghost: { lag: [0.04, 0.09], opacity: [0.14, 0.06] },
    } as const;
    
    /** Spring presets. */
    export const spring = {
      snappy: { type: "spring", stiffness: 500, damping: 30 },
      smooth: { type: "spring", stiffness: 260, damping: 26 },
      bouncy: { type: "spring", stiffness: 300, damping: 15 },
    } as const satisfies Record<string, Transition>;
    
    /** Stagger between siblings, in seconds (0.03–0.06). */
    export const stagger = {
      tight: 0.03,
      base: 0.045,
      loose: 0.06,
    } as const;
    
    /** Blur + fade + y-offset. Falls back to a plain fade under reduced motion. */
    export function enterVariants(reduceMotion?: boolean | null, y = 8): Variants {
      if (reduceMotion) {
        return {
          hidden: { opacity: 0 },
          visible: { opacity: 1, transition: { duration: duration.base } },
          exit: { opacity: 0, transition: { duration: duration.quick } },
        };
      }
      return {
        hidden: { opacity: 0, y, filter: "blur(4px)" },
        visible: {
          opacity: 1,
          y: 0,
          filter: "blur(0px)",
          transition: { duration: duration.base, ease: ease.out },
        },
        exit: {
          opacity: 0,
          y: -y / 2,
          filter: "blur(4px)",
          transition: { duration: duration.quick, ease: ease.out },
        },
      };
    }
    
    /** Scale 0.96 → 1 for popovers, menus and dialogs. */
    export function overlayVariants(reduceMotion?: boolean | null): Variants {
      if (reduceMotion) {
        return {
          hidden: { opacity: 0 },
          visible: { opacity: 1, transition: { duration: duration.quick } },
          exit: { opacity: 0, transition: { duration: duration.instant } },
        };
      }
      return {
        hidden: { opacity: 0, scale: 0.96, filter: "blur(2px)" },
        visible: {
          opacity: 1,
          scale: 1,
          filter: "blur(0px)",
          transition: { duration: duration.quick, ease: ease.out },
        },
        exit: {
          opacity: 0,
          scale: 0.96,
          filter: "blur(2px)",
          transition: { duration: duration.instant + 0.02, ease: ease.out },
        },
      };
    }
    
    /** Parent variants that stagger their children in. */
    export function staggerVariants(gap: number = stagger.base, delayChildren = 0): Variants {
      return {
        hidden: {},
        visible: { transition: { staggerChildren: gap, delayChildren } },
        exit: { transition: { staggerChildren: gap / 2, staggerDirection: -1 } },
      };
    }
  3. Copy the source

    components/ui/activity-rings/activity-rings.tsx
    "use client";
    
    import { useEffect, useRef, type ComponentProps } from "react";
    import { animate, motion, useInView, useMotionValue, useReducedMotion, useTransform } from "motion/react";
    import { cn } from "@/lib/utils";
    import { ease } from "@/lib/motion";
    
    export interface ActivityRing {
      /** Stable identity; defaults to the label. */
      id?: string;
      /** Ring name, e.g. "Move". */
      label: string;
      /** Progress so far. Can pass the goal; the ring keeps going round. */
      value: number;
      /** Value that closes the ring. */
      goal: number;
      /** Unit shown in the legend, e.g. "kcal". */
      unit?: string;
      /** Ring color. Defaults to --chart-1, --chart-2, --chart-3, --chart-4 from the outside in. */
      color?: string;
    }
    
    export interface ActivityRingsProps
      extends Omit<ComponentProps<"div">, "children" | "onAnimationStart" | "onDrag" | "onDragStart" | "onDragEnd"> {
      /** One to four rings, outermost first. */
      rings: ActivityRing[];
      /** Accessible name for the group. @default "Activity" */
      label?: string;
      /** Diameter in px. @default 180 */
      size?: number;
      /** Ring thickness in px. @default 16 */
      thickness?: number;
      /** Space between rings in px. @default 4 */
      gap?: number;
      /** Show the legend with values and goals. @default true */
      legend?: boolean;
    }
    
    const COLORS = ["var(--chart-1)", "var(--chart-2)", "var(--chart-3)", "var(--chart-4)"];
    const number = new Intl.NumberFormat("en-US", { maximumFractionDigits: 1 });
    
    function Ring({
      radius,
      center,
      thickness,
      color,
      progress,
      delay,
      shown,
      reduceMotion,
    }: {
      radius: number;
      center: number;
      thickness: number;
      color: string;
      progress: number;
      delay: number;
      shown: boolean;
      reduceMotion: boolean;
    }) {
      const p = useMotionValue(0);
      useEffect(() => {
        if (reduceMotion) {
          p.set(progress);
          return;
        }
        if (!shown) return;
        const controls = animate(p, progress, { duration: 1.4, delay, ease: ease.out });
        return () => controls.stop();
      }, [progress, shown, reduceMotion, delay, p]);
    
      const firstLap = useTransform(p, (v) => Math.min(1, Math.max(0, v)));
      const secondLap = useTransform(p, (v) => Math.min(1, Math.max(0, v - 1)));
      const firstVisible = useTransform(p, (v) => (v > 0.002 ? 1 : 0));
      const secondVisible = useTransform(p, (v) => (v > 1.002 ? 1 : 0));
      // Past the goal, the lap's tip casts a small shadow on the ring underneath, so the overlap reads.
      const tipX = useTransform(secondLap, (v) => center + radius * Math.cos(v * Math.PI * 2));
      const tipY = useTransform(secondLap, (v) => center + radius * Math.sin(v * Math.PI * 2));
      const stroke = { cx: center, cy: center, r: radius, fill: "none", stroke: color, strokeWidth: thickness, strokeLinecap: "round" as const };
    
      return (
        <>
          <circle cx={center} cy={center} r={radius} fill="none" stroke={`color-mix(in oklab, ${color} 16%, var(--background))`} strokeWidth={thickness} />
          <motion.circle {...stroke} style={{ pathLength: firstLap, opacity: firstVisible }} />
          <motion.circle
            r={thickness / 2}
            cx={tipX}
            cy={tipY}
            fill={color}
            style={{ opacity: secondVisible, filter: "drop-shadow(0 0 2px rgb(0 0 0 / 0.45))" }}
          />
          <motion.circle {...stroke} style={{ pathLength: secondLap, opacity: secondVisible }} />
        </>
      );
    }
    
    export function ActivityRings({
      rings,
      label = "Activity",
      size = 180,
      thickness = 16,
      gap = 4,
      legend = true,
      className,
      ...props
    }: ActivityRingsProps) {
      const reduceMotion = useReducedMotion() ?? false;
      const ref = useRef<HTMLDivElement>(null);
      const inView = useInView(ref, { once: true, amount: 0.5 });
      const center = size / 2;
      const items = rings.slice(0, 4).map((ring, i) => ({
        ...ring,
        key: ring.id ?? ring.label,
        color: ring.color ?? COLORS[i],
        radius: center - thickness / 2 - i * (thickness + gap),
        progress: ring.goal > 0 ? Math.max(0, ring.value / ring.goal) : 0,
      }));
    
      return (
        <div role="group" aria-label={label} className={cn("flex flex-col items-center gap-6 sm:flex-row sm:gap-8", className)} {...props}>
          <div ref={ref} className="shrink-0" style={{ width: size, height: size }}>
            {/* Rotated so every ring starts at 12 o'clock. */}
            <svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} className="-rotate-90 overflow-visible" aria-hidden="true">
              {items.map((ring, i) =>
                ring.radius > thickness / 2 ? (
                  <Ring
                    key={ring.key}
                    radius={ring.radius}
                    center={center}
                    thickness={thickness}
                    color={ring.color}
                    progress={ring.progress}
                    delay={i * 0.12}
                    shown={inView}
                    reduceMotion={reduceMotion}
                  />
                ) : null,
              )}
            </svg>
          </div>
          {legend ? (
            <ul className="space-y-3">
              {items.map((ring) => (
                <li key={ring.key} className="flex items-start gap-2.5">
                  <span aria-hidden="true" className="mt-1.5 size-2.5 shrink-0 rounded-full" style={{ background: ring.color }} />
                  <div>
                    <div className="text-sm text-muted-foreground">{ring.label}</div>
                    <div className="text-lg font-semibold tracking-tight text-foreground">
                      {number.format(ring.value)}
                      <span className="text-sm font-normal text-muted-foreground">
                        {" "}
                        / {number.format(ring.goal)}
                        {ring.unit ? ` ${ring.unit}` : ""}
                      </span>
                      <span className="ml-2 text-xs font-medium text-muted-foreground tabular-nums">{Math.round(ring.progress * 100)}%</span>
                    </div>
                  </div>
                </li>
              ))}
            </ul>
          ) : null}
        </div>
      );
    }

Usage

import { ActivityRings } from "@/components/ui/activity-rings/activity-rings";

export function Today() {
  return (
    <ActivityRings
      rings={[
        { label: "Steps", value: 8240, goal: 10000 },
        { label: "Active", value: 46, goal: 30, unit: "min" },
        { label: "Water", value: 5, goal: 8, unit: "glasses" },
      ]}
    />
  );
}

API reference

PropTypeDefaultDescription
rings*ActivityRing[]–One to four rings, outermost first.
labelstring"Activity"Accessible name for the group.
sizenumber180Diameter in px.
thicknessnumber16Ring thickness in px.
gapnumber4Space between rings in px.
legendbooleantrueShow the legend with values and goals.

Also accepts every native <div> attribute, including ref and className.

  • A ring past its goal keeps going round; the second lap's tip casts a small shadow so the overlap reads.
  • Colors default to --chart-1…4 from the outside in. Rings are staggered as they fill.

Accessibility

  • The rings are decorative; the legend lists each ring's value, goal and percentage as text. Without the legend, give the group a label that spells them out.
  • With reduced motion enabled, rings appear filled to their values without sweeping.