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