{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "chart-rings",
  "title": "Chart Rings",
  "description": "Concentric activity-style progress rings with per-row goals, springy staggered fills, counting values and second-lap overshoot.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://trytweenly.vercel.app/r/chart-kit.json",
    "utils"
  ],
  "files": [
    {
      "path": "registry/new-york/chart-rings/chart-rings.tsx",
      "content": "\"use client\"\n\nimport { useEffect, useId, useState } from \"react\"\nimport { animate, AnimatePresence, motion, useMotionValue, useTransform } from \"motion/react\"\nimport { cn } from \"@/lib/utils\"\nimport {\n  ChartCard,\n  ChartPattern,\n  chartTheme,\n  createFormatter,\n  polar,\n  toNumber,\n  useChartReveal,\n  type ChartBaseProps,\n  type ChartDatum,\n  type ChartTheme,\n} from \"@/registry/new-york/lib/chart-kit\"\n\nexport interface ChartRingsProps extends ChartBaseProps {\n  /** Rows to plot, one ring per row, outermost first. */\n  data: ChartDatum[]\n  /** Key holding each ring's name, e.g. \"goal\". */\n  nameKey: string\n  /** Key holding each ring's current value, e.g. \"value\". */\n  valueKey: string\n  /** Optional key holding a per-row goal; falls back to `max`. */\n  maxKey?: string\n  /** Goal used when a row has no `maxKey` value. Default: 100 */\n  max?: number\n  /** Optional key holding a CSS color per row. */\n  colorKey?: string\n  /** Ring thickness in px. Default: 14 */\n  thickness?: number\n  /** Space between rings in px. Default: 6 */\n  gap?: number\n  /** Style of the unfilled track. Default: \"hatch\" */\n  trackTexture?: \"solid\" | \"hatch\" | \"none\"\n  /** Round the ends of each ring. Default: true */\n  roundedCaps?: boolean\n  /** Angle where rings start filling, in degrees (0 = 12 o'clock). Default: 0 */\n  startAngle?: number\n  /** What the list next to the rings shows. Default: \"percent\" */\n  showValues?: \"percent\" | \"value\" | \"none\"\n  /** List beside the rings or stacked under them. Default: \"side\" */\n  layout?: \"side\" | \"stacked\"\n  /** Chart diameter in px. Default: 200 */\n  size?: number\n}\n\ninterface Ring {\n  name: string\n  value: number\n  goal: number\n  ratio: number\n  color: string\n  r: number\n}\n\nconst STAGGER = 0.12\n\nfunction arc(r: number, a0: number, a1: number) {\n  if (a1 - a0 >= 359.99) {\n    const [x0, y0] = polar(0, 0, r, a0)\n    const [x1, y1] = polar(0, 0, r, a0 + 180)\n    return `M${x0},${y0}A${r},${r} 0 1 1 ${x1},${y1}A${r},${r} 0 1 1 ${x0},${y0}`\n  }\n  const [x0, y0] = polar(0, 0, r, a0)\n  const [x1, y1] = polar(0, 0, r, a1)\n  return `M${x0},${y0}A${r},${r} 0 ${a1 - a0 > 180 ? 1 : 0} 1 ${x1},${y1}`\n}\n\nfunction RingsGlyph() {\n  return (\n    <svg viewBox=\"0 0 16 16\" className=\"size-3.5\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.8} strokeLinecap=\"round\">\n      <path d=\"M8 2.5a5.5 5.5 0 1 1-5.5 5.5\" />\n      <path d=\"M8 5.5A2.5 2.5 0 1 1 5.5 8\" />\n    </svg>\n  )\n}\n\nexport function ChartRings({\n  data,\n  nameKey,\n  valueKey,\n  maxKey,\n  max = 100,\n  colorKey,\n  thickness = 14,\n  gap = 6,\n  trackTexture = \"hatch\",\n  roundedCaps = true,\n  startAngle = 0,\n  showValues = \"percent\",\n  layout = \"side\",\n  size = 200,\n  title,\n  description,\n  titleSize,\n  surface = \"dark\",\n  accent,\n  palette,\n  icon,\n  radius,\n  bare,\n  animate: animateProp = true,\n  duration = 1.2,\n  delay = 0,\n  once = true,\n  valueFormat = \"number\",\n  currency,\n  decimals,\n  className,\n}: ChartRingsProps) {\n  const theme = chartTheme(surface, accent)\n  const fmt = createFormatter(valueFormat, { currency, decimals })\n  const uid = useId().replace(/[^a-zA-Z0-9_-]/g, \"\")\n  const { ref, shown, still } = useChartReveal(once, animateProp)\n  const [active, setActive] = useState<number | null>(null)\n\n  const derived = [\n    theme.accent,\n    `color-mix(in oklab, ${theme.accent} 58%, #fff)`,\n    `color-mix(in oklab, ${theme.accent} 62%, #000)`,\n    `color-mix(in oklab, ${theme.accent} 32%, #fff)`,\n    `color-mix(in oklab, ${theme.accent} 38%, #000)`,\n  ]\n  const t = Math.max(2, thickness)\n  const R = size / 2 - 3\n  const rings: Ring[] = []\n  data.forEach((row, i) => {\n    const r = R - t / 2 - i * (t + Math.max(0, gap))\n    if (r < t / 2 + 1) return\n    const value = Math.max(0, toNumber(row[valueKey]))\n    const goal = (maxKey && toNumber(row[maxKey])) || max || 1\n    rings.push({\n      name: String(row[nameKey] ?? `Ring ${i + 1}`),\n      value,\n      goal,\n      ratio: Math.min(value / goal, 1.995),\n      color:\n        (colorKey && typeof row[colorKey] === \"string\" && (row[colorKey] as string)) ||\n        palette?.[i % palette.length] ||\n        derived[i % derived.length],\n      r,\n    })\n  })\n  const hole = rings.length ? rings[rings.length - 1].r - t / 2 : 0\n  const trackId = `${uid}-track`\n  const shadowId = `${uid}-shadow`\n  const focus = active !== null ? rings[active] : undefined\n\n  const chart = (\n    <svg\n      viewBox={`${-size / 2} ${-size / 2} ${size} ${size}`}\n      width={size}\n      className=\"h-auto max-w-full shrink-0 overflow-visible\"\n      role=\"img\"\n      aria-label={title ?? \"Progress rings\"}\n      onPointerLeave={() => setActive(null)}\n    >\n      <defs>\n        <ChartPattern id={trackId} color={theme.muted} texture=\"muted\" theme={theme} />\n        <filter id={shadowId} x=\"-100%\" y=\"-100%\" width=\"300%\" height=\"300%\">\n          <feGaussianBlur stdDeviation={Math.max(1.5, t / 7)} />\n        </filter>\n      </defs>\n      {rings.map((ring, i) => (\n        <RingArc\n          key={`${ring.name}-${i}`}\n          ring={ring}\n          index={i}\n          theme={theme}\n          thickness={t}\n          track={trackTexture === \"none\" ? \"none\" : trackTexture === \"solid\" ? theme.track : `url(#${trackId})`}\n          roundedCaps={roundedCaps}\n          startAngle={startAngle}\n          shadowId={shadowId}\n          shown={shown}\n          still={still}\n          duration={duration}\n          delay={delay}\n          dimmed={active !== null && active !== i}\n          onEnter={() => setActive(i)}\n        />\n      ))}\n      {hole > 26 && (\n        <AnimatePresence>\n          {focus && (\n            <motion.g\n              key={active}\n              initial={{ opacity: 0, scale: 0.9 }}\n              animate={{ opacity: 1, scale: 1 }}\n              exit={{ opacity: 0, scale: 0.9 }}\n              transition={{ duration: 0.18 }}\n              className=\"pointer-events-none\"\n            >\n              <text\n                y={Math.min(22, hole * 0.5) * 0.35}\n                textAnchor=\"middle\"\n                fontSize={Math.min(22, hole * 0.5)}\n                fontWeight={700}\n                letterSpacing=\"-0.03em\"\n                fill={theme.fg}\n                style={{ fontVariantNumeric: \"tabular-nums\" }}\n              >\n                {Math.round((focus.value / focus.goal) * 100)}%\n              </text>\n            </motion.g>\n          )}\n        </AnimatePresence>\n      )}\n    </svg>\n  )\n\n  return (\n    <ChartCard\n      theme={theme}\n      title={title}\n      description={description}\n      titleSize={titleSize}\n      icon={icon === undefined ? <RingsGlyph /> : icon}\n      radius={radius}\n      bare={bare}\n      className={className}\n    >\n      <div\n        ref={ref}\n        className={cn(\n          \"flex w-full items-center\",\n          layout === \"side\" ? \"flex-row flex-wrap justify-center gap-x-8 gap-y-6\" : \"flex-col gap-7\"\n        )}\n      >\n        {chart}\n        {showValues !== \"none\" && (\n          <ul\n            className={cn(\n              layout === \"side\" ? \"flex min-w-36 flex-1 flex-col gap-4\" : \"grid w-full gap-4\",\n            )}\n            style={\n              layout === \"stacked\"\n                ? { gridTemplateColumns: `repeat(${Math.min(Math.max(rings.length, 1), 3)}, minmax(0, 1fr))` }\n                : undefined\n            }\n            onPointerLeave={() => setActive(null)}\n          >\n            {rings.map((ring, i) => (\n              <RingValue\n                key={`${ring.name}-${i}`}\n                ring={ring}\n                index={i}\n                theme={theme}\n                mode={showValues}\n                fmt={fmt}\n                shown={shown}\n                still={still}\n                duration={duration}\n                delay={delay}\n                dimmed={active !== null && active !== i}\n                centered={layout === \"stacked\"}\n                onEnter={() => setActive(i)}\n              />\n            ))}\n          </ul>\n        )}\n      </div>\n    </ChartCard>\n  )\n}\n\nfunction RingArc({\n  ring,\n  index,\n  theme,\n  thickness,\n  track,\n  roundedCaps,\n  startAngle,\n  shadowId,\n  shown,\n  still,\n  duration,\n  delay,\n  dimmed,\n  onEnter,\n}: {\n  ring: Ring\n  index: number\n  theme: ChartTheme\n  thickness: number\n  track: string\n  roundedCaps: boolean\n  startAngle: number\n  shadowId: string\n  shown: boolean\n  still: boolean\n  duration: number\n  delay: number\n  dimmed: boolean\n  onEnter: () => void\n}) {\n  const p = useMotionValue(still ? ring.ratio : 0)\n\n  useEffect(() => {\n    if (still) {\n      p.set(ring.ratio)\n      return\n    }\n    if (!shown) {\n      p.set(0)\n      return\n    }\n    const c = animate(p, ring.ratio, {\n      type: \"spring\",\n      duration: Math.max(0.3, duration),\n      bounce: 0.28,\n      delay: delay + index * STAGGER,\n    })\n    return () => c.stop()\n  }, [p, ring.ratio, shown, still, duration, delay, index])\n\n  const { r } = ring\n  const lap1 = useTransform(p, (v) => (v <= 0.002 ? \"\" : arc(r, startAngle, startAngle + Math.min(v, 1) * 360)))\n  const lap2 = useTransform(p, (v) => (v <= 1.002 ? \"\" : arc(r, startAngle, startAngle + (v - 1) * 360)))\n  const nudge = ((thickness * 0.18) / r) * (180 / Math.PI)\n  const sx = useTransform(p, (v) => polar(0, 0, r, startAngle + (v - 1) * 360 + nudge)[0])\n  const sy = useTransform(p, (v) => polar(0, 0, r, startAngle + (v - 1) * 360 + nudge)[1])\n  const shadowOpacity = useTransform(p, [1, 1.04], [0, 0.55])\n  const cap = roundedCaps ? \"round\" : \"butt\"\n\n  return (\n    <motion.g\n      initial={false}\n      animate={{ opacity: dimmed ? 0.3 : 1 }}\n      transition={{ duration: 0.2 }}\n      onPointerEnter={onEnter}\n      className=\"cursor-pointer\"\n    >\n      <circle r={r} fill=\"none\" stroke={track === \"none\" ? \"transparent\" : track} strokeWidth={thickness} />\n      <motion.path d={lap1} fill=\"none\" stroke={ring.color} strokeWidth={thickness} strokeLinecap={cap} />\n      <motion.circle\n        cx={sx}\n        cy={sy}\n        r={thickness / 2}\n        fill={theme.surface === \"dark\" ? \"#000\" : \"#3a1a0a\"}\n        filter={`url(#${shadowId})`}\n        style={{ opacity: shadowOpacity }}\n      />\n      <motion.path d={lap2} fill=\"none\" stroke={ring.color} strokeWidth={thickness} strokeLinecap={cap} />\n    </motion.g>\n  )\n}\n\nfunction RingValue({\n  ring,\n  index,\n  theme,\n  mode,\n  fmt,\n  shown,\n  still,\n  duration,\n  delay,\n  dimmed,\n  centered,\n  onEnter,\n}: {\n  ring: Ring\n  index: number\n  theme: ChartTheme\n  mode: \"percent\" | \"value\"\n  fmt: (v: number) => string\n  shown: boolean\n  still: boolean\n  duration: number\n  delay: number\n  dimmed: boolean\n  centered: boolean\n  onEnter: () => void\n}) {\n  const count = useMotionValue(still ? 1 : 0)\n\n  useEffect(() => {\n    if (still) {\n      count.set(1)\n      return\n    }\n    if (!shown) {\n      count.set(0)\n      return\n    }\n    const c = animate(count, 1, {\n      duration: Math.max(0.3, duration),\n      delay: delay + index * STAGGER,\n      ease: [0.16, 1, 0.3, 1],\n    })\n    return () => c.stop()\n  }, [count, shown, still, duration, delay, index])\n\n  const whole = Number.isInteger(ring.value)\n  const main = useTransform(count, (k) =>\n    mode === \"percent\"\n      ? `${Math.round(((ring.value * k) / ring.goal) * 100)}%`\n      : fmt(whole ? Math.round(ring.value * k) : ring.value * k)\n  )\n\n  return (\n    <motion.li\n      initial={false}\n      animate={{ opacity: dimmed ? 0.4 : 1 }}\n      transition={{ duration: 0.2 }}\n      onPointerEnter={onEnter}\n      className={cn(\"min-w-0\", centered && \"text-center\")}\n    >\n      <div className={cn(\"flex items-center gap-2 text-[12.5px]\", centered && \"justify-center\")} style={{ color: theme.muted }}>\n        <span className=\"h-2.5 w-1 shrink-0 rounded-full\" style={{ background: ring.color }} />\n        <span className=\"truncate\">{ring.name}</span>\n      </div>\n      <div className={cn(\"mt-1 flex items-baseline gap-1.5\", centered && \"justify-center\")}>\n        <motion.span className=\"text-[22px] font-semibold leading-none tracking-tight tabular-nums\">{main}</motion.span>\n        <span className=\"truncate text-[12px] tabular-nums\" style={{ color: theme.muted }}>\n          {mode === \"percent\" ? `${fmt(ring.value)} / ${fmt(ring.goal)}` : `/ ${fmt(ring.goal)}`}\n        </span>\n      </div>\n    </motion.li>\n  )\n}\n",
      "type": "registry:component"
    }
  ],
  "docs": "Docs, live playground and every prop: https://trytweenly.vercel.app/docs/components/chart-rings",
  "type": "registry:component"
}