"use client";
import * as React from "react";
import { animate, motion, useMotionValue, useTransform, type MotionValue } from "motion/react";
export interface ActivityRing {
id: string;
label: string;
value: number;
goal: number;
/** Unit shown after values, e.g. "kcal". */
unit?: string;
/** Ring colour; defaults to a palette (accent first). */
color?: string;
}
export interface ActivityRingsProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "children"> {
rings: ActivityRing[];
/** Accessible name of the meters list (and the visual group). */
label?: string;
/** Diameter in px (the rings scale down in narrow containers). */
size?: number;
/** Ring thickness in px at that size. */
thickness?: number;
/** Space between rings in px. */
gap?: number;
/** Opacity of the unfilled track. */
trackOpacity?: number;
/** Spring each ring animates on (instant with reduced motion). */
spring?: { stiffness: number; damping: number; mass: number };
/** Legend with values and goals. */
showLegend?: boolean;
legendPosition?: "right" | "bottom";
/** Summary in the centre: average progress and rings closed. */
showCenter?: boolean;
/** Colour of the first ring without its own colour. */
accent?: string;
format?: (value: number) => string;
}
/* --------------------------- reduced-motion hook --------------------------- */
function subscribeReducedMotion(cb: () => void) {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
mq.addEventListener("change", cb);
return () => mq.removeEventListener("change", cb);
}
function usePrefersReducedMotion() {
return React.useSyncExternalStore(
subscribeReducedMotion,
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
() => false,
);
}
/* --------------------------------- helpers --------------------------------- */
const PALETTE = ["var(--ar-accent)", "#2BC79A", "#6E9BFF", "#F2B33D", "#D96BA0"];
const nf = new Intl.NumberFormat("en-US", { maximumFractionDigits: 0 });
const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
const r2 = (n: number) => Math.round(n * 100) / 100;
/** Springs a number toward `target`, or jumps there with reduced motion. */
function useSpringTo(target: number, spring: { stiffness: number; damping: number; mass: number }, reduce: boolean) {
const mv = useMotionValue(reduce ? target : 0);
const { stiffness, damping, mass } = spring;
React.useEffect(() => {
if (reduce) {
mv.jump(target);
return;
}
const controls = animate(mv, target, { type: "spring", stiffness, damping, mass, restDelta: 0.0005 });
return () => controls.stop();
}, [mv, target, reduce, stiffness, damping, mass]);
return mv;
}
/* ---------------------------------- ring ----------------------------------- */
interface RingProps {
progress: number;
r: number;
c: number;
thickness: number;
color: string;
trackOpacity: number;
spring: { stiffness: number; damping: number; mass: number };
reduce: boolean;
shadowId: string;
}
/**
* One ring: the first lap, then — past 100% — a darker overlap lap whose end cap casts a shadow
* onto the ring underneath, so the wrap reads as layered.
*/
function Ring({ progress, r, c, thickness, color, trackOpacity, spring, reduce, shadowId }: RingProps) {
const mv: MotionValue<number> = useSpringTo(Math.max(0, progress), spring, reduce);
const lap1 = useTransform(mv, (v) => `${r2(clamp(v, 0, 1) * 1000) / 1000} 1`);
const lap1Opacity = useTransform(mv, (v) => (v > 0.002 ? 1 : 0));
// Laps beyond the second stay fully wrapped.
const overflow = (v: number) => (v <= 1 ? 0 : v >= 2 ? 0.9999 : v - 1);
const lap2 = useTransform(mv, (v) => `${r2(overflow(v) * 1000) / 1000} 1`);
const lap2Opacity = useTransform(mv, (v) => (v > 1.002 ? 1 : 0));
// End cap of the overlap lap, positioned on the circle.
const angle = (v: number) => overflow(v) * Math.PI * 2 - Math.PI / 2;
const capX = useTransform(mv, (v) => r2(c + r * Math.cos(angle(v))));
const capY = useTransform(mv, (v) => r2(c + r * Math.sin(angle(v))));
const dark = `color-mix(in oklab, ${color} 78%, black)`;
return (
<g>
<circle cx={c} cy={c} r={r} fill="none" stroke={color} strokeOpacity={trackOpacity} strokeWidth={thickness} />
<motion.circle
cx={c}
cy={c}
r={r}
fill="none"
stroke={color}
strokeWidth={thickness}
strokeLinecap="round"
pathLength={1}
transform={`rotate(-90 ${c} ${c})`}
style={{ strokeDasharray: lap1, opacity: lap1Opacity }}
/>
<motion.g style={{ opacity: lap2Opacity }}>
<motion.circle
cx={c}
cy={c}
r={r}
fill="none"
stroke={dark}
strokeWidth={thickness}
strokeLinecap="round"
pathLength={1}
transform={`rotate(-90 ${c} ${c})`}
style={{ strokeDasharray: lap2 }}
/>
<motion.circle cx={capX} cy={capY} r={thickness / 2} fill={dark} filter={`url(#${shadowId})`} />
</motion.g>
</g>
);
}
/** Centre read-out: average progress, tweened. */
function CenterPercent({ value, spring, reduce }: { value: number; spring: RingProps["spring"]; reduce: boolean }) {
const mv = useSpringTo(value, spring, reduce);
const text = useTransform(mv, (v) => `${Math.round(v * 100)}%`);
return <motion.span>{text}</motion.span>;
}
/* -------------------------------- component -------------------------------- */
/**
* Concentric progress rings for goals. Each ring springs to its value; past 100% it keeps going
* as a darker overlap lap whose end cap casts a shadow. A legend lists values and goals, and each
* goal is exposed to assistive tech as a meter.
*/
export function ActivityRings({
rings,
label = "Goals",
size = 220,
thickness = 20,
gap = 4,
trackOpacity = 0.16,
spring = { stiffness: 120, damping: 18, mass: 1 },
showLegend = true,
legendPosition = "right",
showCenter = true,
accent = "#FF7448",
format,
className = "",
style,
...props
}: ActivityRingsProps) {
const reduce = usePrefersReducedMotion();
const rootRef = React.useRef<HTMLDivElement>(null);
const [seen, setSeen] = React.useState(false);
const shadowId = `${React.useId().replace(/:/g, "")}-cap`;
const fmt = format ?? ((v: number) => nf.format(v));
const items = rings.map((ring, i) => {
const goal = Math.max(1e-9, ring.goal);
const value = Math.max(0, ring.value);
return { ...ring, value, goal, progress: value / goal, color: ring.color ?? PALETTE[i % PALETTE.length] };
});
const closed = items.filter((r) => r.progress >= 1).length;
const average = items.length ? items.reduce((s, r) => s + Math.min(r.progress, 2), 0) / items.length : 0;
// Rings fill the first time they scroll into view (immediately with reduced motion).
React.useEffect(() => {
const el = rootRef.current;
if (!el || seen) return;
const io = new IntersectionObserver(
([entry]) => {
if (entry?.isIntersecting) {
setSeen(true);
io.disconnect();
}
},
{ threshold: 0.3 },
);
io.observe(el);
return () => io.disconnect();
}, [seen]);
const live = seen || reduce;
const pad = 3; // room for the cap shadow
const c = size / 2;
const radii = items.map((_, i) => c - pad - thickness / 2 - i * (thickness + gap));
const innerR = radii.length ? radii[radii.length - 1] - thickness / 2 : c;
const visible = radii.filter((r) => r > thickness / 2);
const meterText = (r: (typeof items)[number]) => {
const unit = r.unit ? ` ${r.unit}` : "";
const pct = Math.round(r.progress * 100);
return `${fmt(r.value)} of ${fmt(r.goal)}${unit}, ${pct}%${r.progress >= 1 ? ", goal reached" : ""}`;
};
const meters = (
<ul aria-label={label} className={showLegend ? "flex min-w-0 flex-col gap-3" : "sr-only"}>
{items.map((r) => (
<li key={r.id}>
<div
role="meter"
aria-label={r.label}
aria-valuemin={0}
aria-valuemax={r.goal}
aria-valuenow={Math.min(r.value, r.goal)}
aria-valuetext={meterText(r)}
className="grid grid-cols-[auto_minmax(0,1fr)] items-start gap-x-3"
>
<span aria-hidden="true" className="mt-1 size-2.5 rounded-full" style={{ backgroundColor: r.color }} />
<span className="flex min-w-0 flex-col">
<span className="flex items-baseline justify-between gap-3">
<span className="truncate font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 dark:text-zinc-400">{r.label}</span>
<span className="font-mono text-[10px] tabular-nums text-zinc-500 dark:text-zinc-400">{Math.round(r.progress * 100)}%</span>
</span>
<span className="text-[15px] font-semibold tracking-[-0.02em] tabular-nums">
{fmt(r.value)}
<span className="font-normal text-zinc-400 dark:text-zinc-500">
{" "}
/ {fmt(r.goal)}
{r.unit ? ` ${r.unit}` : ""}
</span>
</span>
</span>
</div>
</li>
))}
</ul>
);
return (
<div
ref={rootRef}
className={`flex w-full min-w-0 items-center gap-6 text-zinc-950 dark:text-zinc-50 ${
legendPosition === "right" ? "flex-col sm:flex-row" : "flex-col"
} ${className}`}
style={{ ["--ar-accent" as string]: accent, ...style }}
{...props}
>
<div className="relative aspect-square w-full shrink-0 @container" style={{ maxWidth: size }}>
<svg viewBox={`0 0 ${size} ${size}`} className="absolute inset-0 size-full overflow-visible" aria-hidden="true">
<defs>
<filter id={shadowId} x="-50%" y="-50%" width="200%" height="200%">
<feDropShadow dx="0" dy="0" stdDeviation={Math.max(1.5, thickness / 7)} floodColor="#000" floodOpacity="0.45" />
</filter>
</defs>
{items.slice(0, visible.length).map((r, i) => (
<Ring
key={r.id}
progress={live ? r.progress : 0}
r={radii[i]}
c={c}
thickness={thickness}
color={r.color}
trackOpacity={trackOpacity}
spring={spring}
reduce={reduce}
shadowId={shadowId}
/>
))}
</svg>
{showCenter && innerR > 28 && (
<div aria-hidden="true" className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center text-center">
<span className="text-[max(16px,12cqw)] leading-none font-semibold tracking-[-0.04em] tabular-nums">
<CenterPercent value={live ? average : 0} spring={spring} reduce={reduce} />
</span>
<span className="mt-1 font-mono text-[max(8px,4cqw)] uppercase tracking-[0.14em] text-zinc-500 dark:text-zinc-400">
{closed}/{items.length} closed
</span>
</div>
)}
</div>
{meters}
</div>
);
}