{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"activity-rings","type":"registry:component","title":"Activity rings","description":"Three daily goals as concentric rings beside a tight legend, with the week underneath: pick a day and the rings sweep to it.","author":"Hairline UI","dependencies":[],"registryDependencies":["https://hairlineui.com/r/number-roll.json","https://hairlineui.com/r/text-morph.json"],"files":[{"path":"registry/activity-rings.tsx","type":"registry:component","content":"\"use client\";\n\nimport * as React from \"react\";\nimport { NumberRoll } from \"./number-roll\";\nimport { TextMorph } from \"./text-morph\";\n\n/* ─────────────────────────────────────────────────────────\n * ACTIVITY RINGS: three daily goals as concentric rings\n *\n *   arrive    the first time it's on screen the rings sweep\n *             round from the top, outer first, and every figure\n *             rolls up from zero\n *   day       the week sits underneath as seven small rings:\n *             pick a day and the big rings sweep to it, the\n *             day's name morphs and every figure rolls; days\n *             still to come are quiet and can't be picked\n *   past      a goal beaten carries on round for a second lap,\n *             with a dot marking where it's got to\n *   focus     point at a ring or its row and the other two step\n *             back\n *\n * The legend is real text (figure, goal and share), the week is\n * a radio group, and the rings themselves are decoration.\n * ───────────────────────────────────────────────────────── */\n\nexport interface RingGoal {\n  id: string;\n  label: string;\n  /** The day's target. */\n  goal: number;\n  format?: Intl.NumberFormatOptions;\n}\n\nexport interface RingDay {\n  key: string;\n  /** Under the small ring, e.g. \"F\". */\n  label: string;\n  /** In the title, e.g. \"Friday 9 October\". */\n  title?: string;\n  /** One per goal; null for a day still to come. */\n  values: (number | null)[];\n}\n\nexport interface ActivityRingsProps extends Omit<React.HTMLAttributes<HTMLDivElement>, \"onChange\"> {\n  /** Up to three, outermost first. */\n  goals: RingGoal[];\n  /** The week, oldest first. */\n  days: RingDay[];\n  value?: string;\n  defaultValue?: string;\n  onValueChange?: (key: string) => void;\n  locales?: string | string[];\n}\n\nconst EASE = \"cubic-bezier(0.16,1,0.3,1)\";\nconst THROW = \"cubic-bezier(0.34,1.36,0.64,1)\";\nconst FOCUS = \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40\";\nconst COLORS = [\"var(--chart-1, var(--primary))\", \"var(--chart-2, #e8743b)\", \"var(--chart-3, #1baf7a)\"];\nconst BIG = { size: 132, stroke: 12, gap: 3 };\nconst SMALL = { size: 28, stroke: 3, gap: 1 };\n\nconst reducedQuery = \"(prefers-reduced-motion: reduce)\";\nconst subscribeReduced = (onChange: () => void) => {\n  const query = window.matchMedia(reducedQuery);\n  query.addEventListener(\"change\", onChange);\n  return () => query.removeEventListener(\"change\", onChange);\n};\nconst useReducedMotion = () =>\n  React.useSyncExternalStore(subscribeReduced, () => window.matchMedia(reducedQuery).matches, () => false);\n\nconst radius = (g: typeof BIG, i: number) => g.size / 2 - g.stroke / 2 - i * (g.stroke + g.gap);\n\n/** One ring: its track, the first lap, and a second lap (with a dot at its lead) when the goal is beaten. */\nfunction Ring({ geo, i, share, color, dim, reduced, delay = 0, tip = true }: { geo: typeof BIG; i: number; share: number; color: string; dim: boolean; reduced: boolean; delay?: number; tip?: boolean }) {\n  const r = radius(geo, i);\n  const c = 2 * Math.PI * r;\n  const lap = Math.min(1, Math.max(0, share));\n  const over = Math.min(1, Math.max(0, share - 1));\n  const centre = geo.size / 2;\n  const angle = over * 2 * Math.PI - Math.PI / 2;\n  const sweep = reduced ? \"none\" : `stroke-dashoffset 1100ms ${EASE} ${delay}ms, opacity 300ms ${EASE}`;\n  return (\n    <g style={{ opacity: dim ? 0.3 : 1, transition: reduced ? \"none\" : `opacity 300ms ${EASE}` }}>\n      <circle cx={centre} cy={centre} r={r} fill=\"none\" stroke={`color-mix(in oklab, ${color} 16%, var(--background))`} strokeWidth={geo.stroke} />\n      <circle\n        cx={centre}\n        cy={centre}\n        r={r}\n        fill=\"none\"\n        stroke={color}\n        strokeWidth={geo.stroke}\n        strokeLinecap=\"round\"\n        strokeDasharray={c}\n        transform={`rotate(-90 ${centre} ${centre})`}\n        style={{ strokeDashoffset: c * (1 - lap), opacity: lap > 0 ? 1 : 0, transition: sweep }}\n      />\n      {tip && (\n        <>\n          <circle\n            cx={centre}\n            cy={centre}\n            r={r}\n            fill=\"none\"\n            stroke={color}\n            strokeWidth={geo.stroke}\n            strokeLinecap=\"round\"\n            strokeDasharray={c}\n            transform={`rotate(-90 ${centre} ${centre})`}\n            style={{ strokeDashoffset: c * (1 - over), opacity: over > 0 ? 1 : 0, transition: sweep }}\n          />\n          {/* Where the second lap has got to: a dot ringed in the surface colour, so it reads over the first lap. */}\n          <circle\n            cx={centre + r * Math.cos(angle)}\n            cy={centre + r * Math.sin(angle)}\n            r={geo.stroke / 2 - 1}\n            fill={color}\n            stroke=\"var(--background)\"\n            strokeWidth={2}\n            style={{ opacity: over > 0.02 ? 1 : 0, transition: reduced ? \"none\" : `opacity 300ms ${EASE} ${over > 0.02 ? 900 + delay : 0}ms` }}\n          />\n        </>\n      )}\n    </g>\n  );\n}\n\nexport function ActivityRings({ goals: allGoals, days, value, defaultValue, onValueChange, locales, className = \"\", ...props }: ActivityRingsProps) {\n  const reduced = useReducedMotion();\n  const goals = allGoals.slice(0, 3);\n  const lastDay = Math.max(0, days.reduce((last, d, i) => (d.values.some((v) => v !== null) ? i : last), 0));\n  const [own, setOwn] = React.useState(defaultValue ?? days[lastDay]?.key);\n  const dayKey = value ?? own;\n  const index = Math.max(0, days.findIndex((d) => d.key === dayKey));\n  const day = days[index];\n  const [visible, setVisible] = React.useState(false);\n  const [focus, setFocus] = React.useState<number | null>(null);\n  const rootRef = React.useRef<HTMLDivElement>(null);\n  const dayRefs = React.useRef<(HTMLButtonElement | null)[]>([]);\n\n  React.useEffect(() => {\n    const el = rootRef.current;\n    if (!el) return;\n    const observer = new IntersectionObserver(\n      ([entry]) => {\n        if (entry.isIntersecting) {\n          setVisible(true);\n          observer.disconnect();\n        }\n      },\n      { rootMargin: \"0px 0px -10% 0px\" },\n    );\n    observer.observe(el);\n    return () => observer.disconnect();\n  }, []);\n\n  const pick = (i: number, moveFocus = false) => {\n    const target = days[i];\n    if (!target || i > lastDay) return;\n    if (value === undefined) setOwn(target.key);\n    onValueChange?.(target.key);\n    if (moveFocus) dayRefs.current[i]?.focus();\n  };\n\n  const share = (g: number, d = day) => (d && d.values[g] != null ? (d.values[g] as number) / (goals[g].goal || 1) : 0);\n  const fmt = (n: number, g: RingGoal) => new Intl.NumberFormat(locales, g.format).format(n);\n\n  return (\n    <div ref={rootRef} className={`@container w-full rounded-[22px] bg-background shadow-[0_0_0_1px_var(--border)] ${className}`} {...props}>\n      <div className=\"p-4 @md:p-5\">\n        <p className=\"text-[12px] text-muted-foreground @md:text-[13px]\">\n          <TextMorph>{day?.title ?? day?.label ?? \"\"}</TextMorph>\n        </p>\n\n        <div className=\"mt-3 flex items-center gap-4 @md:gap-6\" onPointerLeave={() => setFocus(null)}>\n          <svg aria-hidden=\"true\" viewBox={`0 0 ${BIG.size} ${BIG.size}`} className=\"w-[108px] shrink-0 @md:w-[132px]\">\n            {goals.map((g, i) => (\n              <Ring key={g.id} geo={BIG} i={i} share={visible ? share(i) : 0} color={COLORS[i]} dim={focus !== null && focus !== i} reduced={reduced} delay={visible ? i * 120 : 0} />\n            ))}\n          </svg>\n\n          <ul className=\"min-w-0 flex-1 divide-y divide-border\">\n            {goals.map((g, i) => {\n              const v = day?.values[i];\n              const s = share(i);\n              return (\n                <li\n                  key={g.id}\n                  onPointerEnter={() => setFocus(i)}\n                  className=\"py-1.5 transition-opacity duration-300 first:pt-0 last:pb-0 @md:py-2\"\n                  style={{ opacity: focus !== null && focus !== i ? 0.45 : 1 }}\n                >\n                  <span className=\"flex items-center gap-1.5 text-[11.5px] text-muted-foreground @md:text-[12px]\">\n                    <span aria-hidden=\"true\" className=\"size-[7px] shrink-0 rounded-full\" style={{ background: COLORS[i] }} />\n                    <span className=\"truncate\">{g.label}</span>\n                  </span>\n                  <span className=\"mt-0.5 flex items-baseline gap-1 whitespace-nowrap\">\n                    <span className=\"text-[14px] font-medium tracking-[-0.01em] text-foreground @md:text-[15px]\">\n                      <NumberRoll value={visible && v != null ? v : 0} format={g.format} locales={locales} duration={900} />\n                    </span>\n                    <span className=\"text-[11px] text-muted-foreground @md:text-[11.5px]\">/ {fmt(g.goal, g)}</span>\n                    <span className={`ml-auto text-[11px] tabular-nums @md:text-[11.5px] ${s >= 1 ? \"font-medium text-foreground\" : \"text-muted-foreground\"}`}>\n                      <NumberRoll value={visible ? s : 0} format={{ style: \"percent\" }} duration={900} />\n                    </span>\n                  </span>\n                </li>\n              );\n            })}\n          </ul>\n        </div>\n\n        {/* The week: seven small rings; pick one to see that day. */}\n        <div\n          role=\"radiogroup\"\n          aria-label=\"Day\"\n          className=\"mt-4 grid grid-cols-7 gap-0.5 border-t border-border pt-3 @md:mt-5\"\n          onKeyDown={(e) => {\n            const moves: Record<string, number> = { ArrowLeft: index - 1, ArrowRight: index + 1, Home: 0, End: lastDay };\n            if (!(e.key in moves)) return;\n            e.preventDefault();\n            pick(Math.min(lastDay, Math.max(0, moves[e.key])), true);\n          }}\n        >\n          {days.map((d, i) => {\n            const selected = i === index;\n            const future = i > lastDay;\n            return (\n              <button\n                key={d.key}\n                ref={(el) => {\n                  dayRefs.current[i] = el;\n                }}\n                type=\"button\"\n                role=\"radio\"\n                aria-checked={selected}\n                aria-disabled={future || undefined}\n                aria-label={`${d.title ?? d.label}${future ? \", still to come\" : `: ${goals.map((g, gi) => `${g.label} ${Math.round(share(gi, d) * 100)}%`).join(\", \")}`}`}\n                tabIndex={selected ? 0 : -1}\n                onClick={() => pick(i)}\n                className={`flex flex-col items-center gap-1 rounded-[10px] py-1.5 transition-colors duration-300 ${FOCUS} ${selected ? \"bg-accent\" : future ? \"cursor-default\" : \"hover:bg-accent/60\"}`}\n              >\n                <svg aria-hidden=\"true\" viewBox={`0 0 ${SMALL.size} ${SMALL.size}`} className=\"w-6 @md:w-7\" style={{ opacity: future ? 0.35 : 1 }}>\n                  {goals.map((g, gi) => (\n                    <Ring key={g.id} geo={SMALL} i={gi} share={visible ? share(gi, d) : 0} color={COLORS[gi]} dim={false} reduced={reduced} delay={visible ? i * 40 : 0} tip={false} />\n                  ))}\n                </svg>\n                <span className={`text-[10.5px] leading-none @md:text-[11px] ${selected ? \"font-medium text-foreground\" : \"text-muted-foreground\"}`}>{d.label}</span>\n              </button>\n            );\n          })}\n        </div>\n      </div>\n    </div>\n  );\n}\n"}],"css":{},"docs":"Docs and live preview: https://hairlineui.com/ui/activity-rings"}