Numbers & Data Displayv0.1.0
Activity Rings
Three concentric rings for move, exercise and stand.
Installation
CLI
npx shadcn@latest add https://hantui-theta.vercel.app/r/activity-rings.jsonManual
Install the dependencies
npm install clsx motion tailwind-mergeAdd the shared helpers
Components use
cn()fromlib/utils.tsand the motion tokens below. See Installation if you have not set them up yet.lib/motion.tsimport 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 } }, }; }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
| Prop | Type | Default | Description |
|---|---|---|---|
rings* | ActivityRing[] | – | One to four rings, outermost first. |
label | string | "Activity" | Accessible name for the group. |
size | number | 180 | Diameter in px. |
thickness | number | 16 | Ring thickness in px. |
gap | number | 4 | Space between rings in px. |
legend | boolean | true | Show 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.