{
  "name": "spending-ring",
  "type": "registry:component",
  "files": [
    {
      "type": "registry:component",
      "content": "\"use client\";\n\n/**\n * @author: @dorianbaffier\n * @description: Spending Ring\n * @version: 1.0.0\n * @date: 2026-10-15\n * @license: MIT\n * @website: https://kokonutui.com\n * @github: https://github.com/kokonut-labs/kokonutui\n */\n\nimport {\n  type KeyboardEvent,\n  type PointerEvent,\n  useCallback,\n  useEffect,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport { cn } from \"@/lib/utils\";\n\nconst SIZE = 240;\nconst C = SIZE / 2;\nconst R = 90;\nconst STROKE = 22;\n// Degrees left empty between categories.\nconst GAP = 2.4;\n// Spring for the ring's angle: rate in rad/s and damping ratio. A touch under\n// critical so a flick settles onto its category with a small give.\nconst RATE = 11;\nconst DAMPING = 0.78;\n// How far ahead a flick is projected when choosing where it lands, in seconds.\nconst PROJECT = 0.2;\n// Movement under this many pixels is a tap, not a spin.\nconst TAP_SLOP = 5;\n\nexport interface SpendingCategory {\n  name: string;\n  amount: number;\n  /** Change from last month, in percent. */\n  change?: number;\n  /** Tailwind classes for the segment stroke and the legend dot. */\n  stroke: string;\n  dot: string;\n}\n\nexport interface SpendingRingProps {\n  title?: string;\n  categories?: SpendingCategory[];\n  currency?: string;\n  className?: string;\n}\n\nconst DEFAULT_CATEGORIES: SpendingCategory[] = [\n  {\n    name: \"Housing\",\n    amount: 1250,\n    change: 0,\n    stroke: \"stroke-sky-500 dark:stroke-sky-400\",\n    dot: \"bg-sky-500 dark:bg-sky-400\",\n  },\n  {\n    name: \"Groceries\",\n    amount: 486.2,\n    change: -6.1,\n    stroke: \"stroke-emerald-500 dark:stroke-emerald-400\",\n    dot: \"bg-emerald-500 dark:bg-emerald-400\",\n  },\n  {\n    name: \"Shopping\",\n    amount: 398.1,\n    change: 22.4,\n    stroke: \"stroke-rose-500 dark:stroke-rose-400\",\n    dot: \"bg-rose-500 dark:bg-rose-400\",\n  },\n  {\n    name: \"Dining out\",\n    amount: 312.75,\n    change: 12.8,\n    stroke: \"stroke-amber-400 dark:stroke-amber-300\",\n    dot: \"bg-amber-400 dark:bg-amber-300\",\n  },\n  {\n    name: \"Transport\",\n    amount: 214.4,\n    change: -3.5,\n    stroke: \"stroke-violet-500 dark:stroke-violet-400\",\n    dot: \"bg-violet-500 dark:bg-violet-400\",\n  },\n  {\n    name: \"Subscriptions\",\n    amount: 86.97,\n    change: 0,\n    stroke: \"stroke-zinc-400 dark:stroke-zinc-500\",\n    dot: \"bg-zinc-400 dark:bg-zinc-500\",\n  },\n];\n\n/** Wraps an angle into -180..180. */\nconst wrap = (deg: number) => ((((deg + 180) % 360) + 360) % 360) - 180;\n\n/** A point on the ring, with 0° at the top and angles running clockwise. */\nfunction polar(deg: number, r = R) {\n  const rad = (deg * Math.PI) / 180;\n  return [C + r * Math.sin(rad), C - r * Math.cos(rad)] as const;\n}\n\nfunction arc(from: number, to: number) {\n  const [x0, y0] = polar(from);\n  const [x1, y1] = polar(to);\n  const large = to - from > 180 ? 1 : 0;\n  return `M${x0.toFixed(2)} ${y0.toFixed(2)} A${R} ${R} 0 ${large} 1 ${x1.toFixed(2)} ${y1.toFixed(2)}`;\n}\n\n/** The category whose slice covers an angle on the ring. */\nfunction sliceAt(spans: { from: number; to: number }[], deg: number) {\n  const a = ((deg % 360) + 360) % 360;\n  const index = spans.findIndex((s) => a >= s.from && a < s.to);\n  return index === -1 ? 0 : index;\n}\n\n/** One step of a damped spring, in small slices for stability. */\nfunction springStep(\n  state: { a: number; v: number },\n  target: number,\n  dt: number\n) {\n  const slices = Math.ceil(dt / (1 / 240));\n  const h = dt / slices;\n  for (let i = 0; i < slices; i++) {\n    const accel =\n      -RATE * RATE * (state.a - target) - 2 * DAMPING * RATE * state.v;\n    state.v += accel * h;\n    state.a += state.v * h;\n  }\n  return Math.abs(state.a - target) < 0.05 && Math.abs(state.v) < 2;\n}\n\nexport default function SpendingRing({\n  title = \"October spending\",\n  categories = DEFAULT_CATEGORIES,\n  currency = \"USD\",\n  className,\n}: SpendingRingProps) {\n  const [selected, setSelected] = useState(0);\n  const [spinning, setSpinning] = useState(false);\n  const ringRef = useRef<SVGGElement>(null);\n  const surfaceRef = useRef<HTMLDivElement>(null);\n  const selectedRef = useRef(0);\n\n  const total = categories.reduce((sum, c) => sum + c.amount, 0);\n  const spans = useMemo(() => {\n    let at = 0;\n    return categories.map((c) => {\n      const sweep = total ? (c.amount / total) * 360 : 360 / categories.length;\n      const span = { from: at, to: at + sweep, mid: at + sweep / 2 };\n      at += sweep;\n      return span;\n    });\n  }, [categories, total]);\n\n  // The ring turns so the selected slice sits under the marker at the top.\n  const sim = useRef({\n    a: -(spans[0]?.mid ?? 0),\n    v: 0,\n    target: -(spans[0]?.mid ?? 0),\n    raf: 0,\n    last: 0,\n    // While a flick coasts, the readout follows whatever passes the marker.\n    coast: false,\n    drag: null as null | {\n      id: number;\n      angle: number;\n      time: number;\n      moved: number;\n      x: number;\n      y: number;\n    },\n  });\n\n  const money = useMemo(\n    () =>\n      new Intl.NumberFormat(\"en-US\", {\n        style: \"currency\",\n        currency,\n        maximumFractionDigits: 2,\n        minimumFractionDigits: 2,\n      }),\n    [currency]\n  );\n\n  const paint = useCallback(() => {\n    ringRef.current?.setAttribute(\n      \"transform\",\n      `rotate(${sim.current.a.toFixed(3)} ${C} ${C})`\n    );\n  }, []);\n\n  /** Show whichever slice is under the marker right now. */\n  const track = useCallback(() => {\n    const index = sliceAt(spans, -sim.current.a);\n    if (index !== selectedRef.current) {\n      selectedRef.current = index;\n      setSelected(index);\n    }\n  }, [spans]);\n\n  const tick = useCallback(\n    (now: number) => {\n      const s = sim.current;\n      s.raf = 0;\n      if (s.drag) {\n        return;\n      }\n      const dt = s.last ? Math.min((now - s.last) / 1000, 1 / 30) : 1 / 60;\n      s.last = now;\n      const state = { a: s.a, v: s.v };\n      const settled = springStep(state, s.target, dt);\n      s.a = settled ? s.target : state.a;\n      s.v = settled ? 0 : state.v;\n      paint();\n      if (s.coast) {\n        track();\n      }\n      if (settled) {\n        s.last = 0;\n        s.coast = false;\n        setSpinning(false);\n      } else {\n        s.raf = requestAnimationFrame(tick);\n      }\n    },\n    [paint, track]\n  );\n\n  /**\n   * Turn the ring to a slice, the short way round from where it would come to\n   * rest on its own (a flick keeps going the way it was thrown).\n   */\n  const turnTo = useCallback(\n    (index: number, rest = sim.current.a, coast = false) => {\n      const s = sim.current;\n      const goal = -(spans[index]?.mid ?? 0);\n      s.target = rest + wrap(goal - rest);\n      s.coast = coast;\n      if (!coast) {\n        selectedRef.current = index;\n        setSelected(index);\n      }\n      if (window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches) {\n        s.a = s.target;\n        s.v = 0;\n        s.coast = false;\n        paint();\n        track();\n        return;\n      }\n      setSpinning(true);\n      if (!s.raf) {\n        s.last = 0;\n        s.raf = requestAnimationFrame(tick);\n      }\n    },\n    [paint, spans, tick, track]\n  );\n\n  // New data keeps the same slice under the marker.\n  useEffect(() => {\n    const s = sim.current;\n    const index = Math.min(selectedRef.current, spans.length - 1);\n    s.a = -(spans[index]?.mid ?? 0);\n    s.target = s.a;\n    s.v = 0;\n    selectedRef.current = index;\n    setSelected(index);\n    paint();\n  }, [spans, paint]);\n\n  useEffect(() => () => cancelAnimationFrame(sim.current.raf), []);\n\n  const pointerAngle = (event: PointerEvent<HTMLDivElement>) => {\n    const rect = event.currentTarget.getBoundingClientRect();\n    const x = event.clientX - rect.left - rect.width / 2;\n    const y = event.clientY - rect.top - rect.height / 2;\n    return (Math.atan2(x, -y) * 180) / Math.PI;\n  };\n\n  const handlePointerDown = (event: PointerEvent<HTMLDivElement>) => {\n    const s = sim.current;\n    if (s.drag || (event.pointerType === \"mouse\" && event.button !== 0)) {\n      return;\n    }\n    event.currentTarget.setPointerCapture(event.pointerId);\n    cancelAnimationFrame(s.raf);\n    s.raf = 0;\n    s.v = 0;\n    s.drag = {\n      id: event.pointerId,\n      angle: pointerAngle(event),\n      time: event.timeStamp,\n      moved: 0,\n      x: event.clientX,\n      y: event.clientY,\n    };\n  };\n\n  const handlePointerMove = (event: PointerEvent<HTMLDivElement>) => {\n    const s = sim.current;\n    const drag = s.drag;\n    if (!drag || drag.id !== event.pointerId) {\n      return;\n    }\n    drag.moved = Math.max(\n      drag.moved,\n      Math.hypot(event.clientX - drag.x, event.clientY - drag.y)\n    );\n    if (drag.moved < TAP_SLOP) {\n      return;\n    }\n    const angle = pointerAngle(event);\n    const delta = wrap(angle - drag.angle);\n    const dt = Math.max((event.timeStamp - drag.time) / 1000, 1 / 240);\n    s.a += delta;\n    // Smoothed angular speed, for the flick on release.\n    s.v = s.v * 0.6 + (delta / dt) * 0.4;\n    drag.angle = angle;\n    drag.time = event.timeStamp;\n    setSpinning(true);\n    paint();\n    track();\n  };\n\n  const release = (event: PointerEvent<HTMLDivElement>) => {\n    const s = sim.current;\n    const drag = s.drag;\n    if (!drag || drag.id !== event.pointerId) {\n      return;\n    }\n    s.drag = null;\n    if (drag.moved < TAP_SLOP) {\n      // A tap on the ring picks the slice under the finger; the middle is\n      // only a readout.\n      const rect = event.currentTarget.getBoundingClientRect();\n      const r = Math.hypot(\n        event.clientX - rect.left - rect.width / 2,\n        event.clientY - rect.top - rect.height / 2\n      );\n      if (r > ((R - STROKE) / SIZE) * rect.width) {\n        turnTo(sliceAt(spans, pointerAngle(event) - s.a));\n      }\n      return;\n    }\n    // A stale drag (held still before letting go) has no flick left in it.\n    if (event.timeStamp - drag.time > 80) {\n      s.v = 0;\n    }\n    s.v = Math.max(-1440, Math.min(1440, s.v));\n    const rest = s.a + s.v * PROJECT;\n    turnTo(sliceAt(spans, -rest), rest, true);\n  };\n\n  const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {\n    const step: Record<string, number> = {\n      ArrowRight: 1,\n      ArrowDown: 1,\n      ArrowLeft: -1,\n      ArrowUp: -1,\n    };\n    let next: number | null = null;\n    if (event.key in step) {\n      next =\n        (selected + step[event.key] + categories.length) % categories.length;\n    } else if (event.key === \"Home\") {\n      next = 0;\n    } else if (event.key === \"End\") {\n      next = categories.length - 1;\n    }\n    if (next !== null) {\n      event.preventDefault();\n      turnTo(next);\n    }\n  };\n\n  const current = categories[selected];\n  const share = total && current ? (current.amount / total) * 100 : 0;\n  const change = current?.change ?? 0;\n\n  return (\n    <div\n      className={cn(\n        \"w-full max-w-sm rounded-3xl border border-zinc-200 bg-white p-5 text-zinc-900 dark:border-zinc-800 dark:bg-zinc-950 dark:text-zinc-100\",\n        className\n      )}\n    >\n      <div className=\"flex items-baseline justify-between gap-4\">\n        <p className=\"text-sm text-zinc-500 dark:text-zinc-400\">{title}</p>\n        <p className=\"font-medium text-sm tabular-nums\">\n          {money.format(total)}\n        </p>\n      </div>\n\n      <div\n        aria-label=\"Spending by category. Use the arrow keys to turn the ring.\"\n        aria-valuemax={categories.length}\n        aria-valuemin={1}\n        aria-valuenow={selected + 1}\n        aria-valuetext={\n          current ? `${current.name}, ${money.format(current.amount)}` : \"\"\n        }\n        className={cn(\n          \"relative mx-auto mt-3 aspect-square w-full max-w-[240px] touch-none select-none rounded-full outline-none focus-visible:ring-2 focus-visible:ring-zinc-400 focus-visible:ring-offset-4 dark:focus-visible:ring-offset-zinc-950\",\n          spinning ? \"cursor-grabbing\" : \"cursor-grab\"\n        )}\n        onKeyDown={handleKeyDown}\n        onLostPointerCapture={release}\n        onPointerCancel={release}\n        onPointerDown={handlePointerDown}\n        onPointerMove={handlePointerMove}\n        onPointerUp={release}\n        ref={surfaceRef}\n        role=\"slider\"\n        tabIndex={0}\n      >\n        <svg\n          aria-hidden=\"true\"\n          className=\"absolute inset-0 size-full\"\n          viewBox={`0 0 ${SIZE} ${SIZE}`}\n        >\n          <g ref={ringRef} transform={`rotate(${sim.current.a} ${C} ${C})`}>\n            {spans.map((span, i) => {\n              const category = categories[i];\n              const on = i === selected;\n              return (\n                <path\n                  className={cn(\n                    \"transition-[stroke-width,opacity] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]\",\n                    category.stroke,\n                    !on && \"opacity-40 dark:opacity-50\"\n                  )}\n                  d={arc(span.from + GAP / 2, span.to - GAP / 2)}\n                  fill=\"none\"\n                  key={category.name}\n                  strokeWidth={on ? STROKE + 8 : STROKE}\n                />\n              );\n            })}\n          </g>\n          {/* The marker the ring turns slices to. */}\n          <path\n            className=\"fill-zinc-900 dark:fill-zinc-100\"\n            d={`M${C - 5} 2 L${C + 5} 2 L${C} 8 Z`}\n          />\n        </svg>\n\n        <div className=\"pointer-events-none absolute inset-0 flex flex-col items-center justify-center text-center\">\n          <div\n            className=\"starting:translate-y-1 starting:opacity-0 transition-[opacity,translate] duration-200 ease-out\"\n            key={current?.name}\n          >\n            <p className=\"flex items-center justify-center gap-1.5 text-xs text-zinc-500 dark:text-zinc-400\">\n              <span className={cn(\"size-1.5 rounded-full\", current?.dot)} />\n              {current?.name}\n            </p>\n            <p className=\"mt-0.5 font-semibold text-2xl tabular-nums tracking-tight\">\n              {current ? money.format(current.amount) : \"\"}\n            </p>\n            <p className=\"mt-0.5 text-xs text-zinc-500 tabular-nums dark:text-zinc-400\">\n              {share.toFixed(0)}% of total\n            </p>\n            <p\n              className={cn(\n                \"mt-0.5 text-xs tabular-nums\",\n                change > 0 && \"text-rose-600 dark:text-rose-400\",\n                change < 0 && \"text-emerald-600 dark:text-emerald-400\",\n                change === 0 && \"text-zinc-400 dark:text-zinc-500\"\n              )}\n            >\n              {change === 0\n                ? \"Same as last month\"\n                : `${change > 0 ? \"▲\" : \"▼\"} ${Math.abs(change).toFixed(1)}% vs last month`}\n            </p>\n          </div>\n        </div>\n      </div>\n\n      <ul className=\"mt-4 grid grid-cols-2 gap-1\">\n        {categories.map((category, i) => (\n          <li key={category.name}>\n            <button\n              aria-pressed={i === selected}\n              className={cn(\n                \"flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-xs outline-none transition-colors duration-150 ease-out focus-visible:ring-2 focus-visible:ring-zinc-400\",\n                i === selected\n                  ? \"bg-zinc-100 dark:bg-zinc-900\"\n                  : \"text-zinc-500 hover:bg-zinc-50 hover:text-zinc-900 dark:text-zinc-400 dark:hover:bg-zinc-900/60 dark:hover:text-zinc-100\"\n              )}\n              onClick={() => turnTo(i)}\n              type=\"button\"\n            >\n              <span\n                aria-hidden=\"true\"\n                className={cn(\"size-2 shrink-0 rounded-full\", category.dot)}\n              />\n              <span className=\"min-w-0 flex-1 truncate\">{category.name}</span>\n              <span className=\"tabular-nums\">\n                {money.format(Math.round(category.amount)).replace(\".00\", \"\")}\n              </span>\n            </button>\n          </li>\n        ))}\n      </ul>\n    </div>\n  );\n}\n",
      "path": "/components/kokonutui/spending-ring.tsx",
      "target": "components/kokonutui/spending-ring.tsx"
    }
  ]
}