{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "split-card",
  "type": "registry:component",
  "title": "Split Card",
  "description": "A total and what it's made of, in one small card: switch the period and the figures roll, the bar re-proportions and the ranked rows quietly trade places.",
  "categories": [
    "component",
    "Numbers"
  ],
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "utils",
    "https://coss.com/ui/r/tabs.json"
  ],
  "files": [
    {
      "path": "registry/devl/split-card.tsx",
      "type": "registry:component",
      "content": "\"use client\";\n\nimport { ArrowUpRightIcon } from \"lucide-react\";\nimport { animate, type MotionValue, motion, useMotionValue, useReducedMotion, useTransform } from \"motion/react\";\nimport { useEffect, useRef, useState } from \"react\";\nimport { Tabs, TabsList, TabsTab } from \"@/components/ui/tabs\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface SplitPart {\n  id: string;\n  label: string;\n  value: number;\n}\n\nexport interface SplitPeriod {\n  id: string;\n  label: string;\n  parts: SplitPart[];\n  previousTotal?: number;\n}\n\nexport interface SplitCardProps {\n  title?: string;\n  periods: SplitPeriod[];\n  defaultPeriod?: string;\n  format?: (value: number) => string;\n  good?: \"up\" | \"down\";\n  maxRows?: number;\n  className?: string;\n}\n\nconst COLORS = [\"var(--color-chart-1)\", \"var(--color-chart-2)\", \"var(--color-chart-3)\", \"var(--color-chart-4)\", \"var(--color-chart-5)\"];\nconst defaultFormat = (v: number) => new Intl.NumberFormat(\"en-US\", { maximumFractionDigits: 0 }).format(v);\nconst GAP_PX = 3;\n\nconst DIGITS = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];\n\nfunction Face({ n, turn }: { n: number; turn: MotionValue<number> }) {\n  const y = useTransform(turn, (t) => {\n    let offset = (((n - t) % 10) + 10) % 10;\n    if (offset > 5) offset -= 10;\n    return `${offset * 100}%`;\n  });\n  return (\n    <motion.span className=\"absolute inset-x-0 top-0 block h-[1.15em] text-center leading-[1.15em]\" style={{ y }}>\n      {n}\n    </motion.span>\n  );\n}\n\nfunction Wheel({ digit, instant, delay }: { digit: number; instant: boolean; delay: number }) {\n  const reduce = useReducedMotion();\n  const turn = useMotionValue(digit);\n  const target = useRef(digit);\n  useEffect(() => {\n    let step = digit - (((target.current % 10) + 10) % 10);\n    if (step > 5) step -= 10;\n    else if (step < -5) step += 10;\n    target.current += step;\n    if (reduce || instant) turn.jump(target.current);\n    else if (step !== 0 || (!turn.isAnimating() && turn.get() !== target.current))\n      animate(turn, target.current, { type: \"spring\", stiffness: 280, damping: 34, delay });\n  }, [digit, instant, delay, reduce, turn]);\n  useEffect(() => () => turn.stop(), [turn]);\n  return (\n    <span className=\"relative inline-block h-[1.15em] overflow-hidden [mask-image:linear-gradient(transparent,black_20%,black_80%,transparent)]\">\n      <span className=\"invisible block leading-[1.15em]\">0</span>\n      {DIGITS.map((n) => (\n        <Face key={n} n={n} turn={turn} />\n      ))}\n    </span>\n  );\n}\n\nfunction Roll({\n  text,\n  className,\n  instant = false,\n  delay = 0,\n}: {\n  text: string;\n  className?: string;\n  instant?: boolean;\n  delay?: number;\n}) {\n  let placeFromRight = 0;\n  let other = 0;\n  const glyphs = [...text]\n    .reverse()\n    .map((char) => {\n      const isDigit = char >= \"0\" && char <= \"9\";\n      return { char, isDigit, key: isDigit ? `d${placeFromRight++}` : `s${other++}${char}` };\n    })\n    .reverse();\n  return (\n    <span className={cn(\"inline-flex whitespace-nowrap tabular-nums leading-none\", className)}>\n      <span className=\"sr-only\">{text}</span>\n      <span aria-hidden className=\"inline-flex\">\n        {glyphs.map((g) =>\n          g.isDigit ? (\n            <Wheel delay={delay} digit={Number(g.char)} instant={instant} key={g.key} />\n          ) : (\n            <span className=\"inline-block h-[1.15em] leading-[1.15em]\" key={g.key}>\n              {g.char === \" \" ? \" \" : g.char}\n            </span>\n          ),\n        )}\n      </span>\n    </span>\n  );\n}\n\nexport function SplitCard({\n  title = \"Breakdown\",\n  periods,\n  defaultPeriod,\n  format = defaultFormat,\n  good = \"up\",\n  maxRows = 5,\n  className,\n}: SplitCardProps) {\n  const reduce = useReducedMotion();\n  const [periodId, setPeriodId] = useState(defaultPeriod ?? periods[0]?.id);\n  const [hovered, setHovered] = useState<string | null>(null);\n  const period = periods.find((p) => p.id === periodId) ?? periods[0];\n\n  const colorIndex = new Map<string, number>();\n  for (const p of periods) for (const part of p.parts) if (!colorIndex.has(part.id)) colorIndex.set(part.id, colorIndex.size);\n  const colorOf = (id: string) => (id === \"__other\" ? \"var(--color-muted-foreground)\" : COLORS[(colorIndex.get(id) ?? 0) % COLORS.length]);\n\n  const total = period.parts.reduce((sum, p) => sum + p.value, 0);\n  const ranked = [...period.parts].sort((a, b) => b.value - a.value);\n  const inBarOrder = [...period.parts].sort((a, b) => (colorIndex.get(a.id) ?? 0) - (colorIndex.get(b.id) ?? 0));\n  const rows =\n    ranked.length > maxRows\n      ? [\n          ...ranked.slice(0, maxRows - 1),\n          { id: \"__other\", label: \"Other\", value: ranked.slice(maxRows - 1).reduce((s, p) => s + p.value, 0) },\n        ]\n      : ranked;\n  const change = period.previousTotal ? (total - period.previousTotal) / period.previousTotal : null;\n  const up = (change ?? 0) >= 0;\n  const better = up === (good === \"up\");\n\n  const glide = reduce ? { duration: 0 } : ({ type: \"spring\", duration: 0.45, bounce: 0 } as const);\n\n  let acc = 0;\n  const segments = inBarOrder.map((part, i) => {\n    const start = total ? (acc / total) * 100 : 0;\n    acc += part.value;\n    const end = total ? (acc / total) * 100 : 0;\n    return { part, start, end, first: i === 0, last: i === inBarOrder.length - 1 };\n  });\n\n  return (\n    <section\n      aria-label={title}\n      className={cn(\"flex w-full max-w-[22rem] flex-col gap-4 rounded-2xl border bg-card p-4 text-card-foreground shadow-xs/5\", className)}\n    >\n      <header className=\"flex items-center justify-between gap-3\">\n        <h3 className=\"font-medium text-sm\">{title}</h3>\n        <Tabs onValueChange={(v) => setPeriodId(String(v))} value={period.id}>\n          <TabsList aria-label={`${title} period`} size=\"sm\">\n            {periods.map((p) => (\n              <TabsTab className=\"px-2 text-xs tabular-nums text-muted-foreground sm:text-xs\" key={p.id} value={p.id}>\n                {p.label}\n              </TabsTab>\n            ))}\n          </TabsList>\n        </Tabs>\n      </header>\n\n      <div className=\"flex items-end justify-between gap-3\">\n        <Roll className=\"font-semibold text-3xl tracking-tight\" text={format(total)} />\n        {change !== null && (\n          <span\n            className={cn(\n              \"mb-0.5 inline-flex items-center gap-0.5 rounded-full py-0.5 ps-1 pe-1.5 font-medium text-xs transition-colors duration-300\",\n              better ? \"bg-success/10 text-success-foreground\" : \"bg-destructive/10 text-destructive-foreground\",\n            )}\n          >\n            <motion.span animate={{ rotate: up ? 0 : 90 }} className=\"inline-flex\" initial={false} transition={glide}>\n              <ArrowUpRightIcon className=\"size-3.5\" />\n            </motion.span>\n            <span className=\"sr-only\">{up ? \"up\" : \"down\"}</span>\n            <Roll text={`${Math.abs(change * 100).toFixed(1)}%`} />\n          </span>\n        )}\n      </div>\n\n      <div aria-hidden className=\"relative h-2.5\">\n        {segments.map(({ part, start, end, first, last }) => {\n          const folded = !rows.some((r) => r.id === part.id);\n          const lit = !hovered || hovered === part.id || (hovered === \"__other\" && folded);\n          const l = first ? 0 : GAP_PX / 2;\n          const r = last ? 0 : GAP_PX / 2;\n          return (\n            <motion.div\n              animate={{\n                clipPath: `inset(0px calc(${(100 - end).toFixed(3)}% + ${r}px) 0px calc(${start.toFixed(3)}% + ${l}px) round 999px)`,\n                opacity: lit ? 1 : 0.25,\n              }}\n              className=\"absolute inset-0\"\n              initial={false}\n              key={part.id}\n              style={{ backgroundColor: colorOf(part.id) }}\n              transition={glide}\n            />\n          );\n        })}\n      </div>\n\n      <div className=\"-mx-1.5 flex\">\n        <ol aria-hidden className=\"flex w-5 shrink-0 flex-col ps-1.5 text-muted-foreground text-xs tabular-nums\">\n          {rows.map((row, i) => (\n            <li className=\"flex h-8 items-center\" key={i}>\n              {i + 1}\n            </li>\n          ))}\n        </ol>\n        <ol className=\"flex min-w-0 flex-1 flex-col\">\n          {rows.map((row) => {\n            const share = total ? row.value / total : 0;\n            return (\n              <motion.li\n                className=\"flex h-8 cursor-default items-center gap-2.5 rounded-lg px-1.5 text-sm transition-colors hover:bg-accent\"\n                key={row.id}\n                layout={reduce ? false : \"position\"}\n                onPointerEnter={() => setHovered(row.id)}\n                onPointerLeave={() => setHovered(null)}\n                transition={glide}\n              >\n                <span className=\"size-2 shrink-0 rounded-full\" style={{ backgroundColor: colorOf(row.id) }} />\n                <span className=\"min-w-0 flex-1 truncate\">{row.label}</span>\n                <Roll className=\"font-medium\" text={format(row.value)} />\n                <span className=\"w-9 text-right text-muted-foreground text-xs tabular-nums\">{Math.round(share * 100)}%</span>\n              </motion.li>\n            );\n          })}\n        </ol>\n      </div>\n    </section>\n  );\n}\n"
    }
  ]
}