{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"stat-cards","type":"registry:component","title":"Stat cards","description":"Four metric cards — headline KPI, scrubbable sparkline, goal ring and two-period comparison — with count-ups, redrawn lines, springing arcs and bars.","author":"GetUrUI <hello@geturui.dev>","dependencies":["motion","lucide-react"],"registryDependencies":[],"files":[{"path":"registry/geturui/examples/stat-cards/kpi.tsx","type":"registry:component","target":"components/examples/stat-cards/kpi.tsx","content":"\"use client\";\n\nimport * as React from \"react\";\nimport { AnimatePresence, motion, useReducedMotion, useSpring, useTransform } from \"motion/react\";\nimport { ArrowDownRight, ArrowUpRight, ChevronDown } from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface KpiSegment {\n  label: string;\n  value: number;\n}\n\nexport interface KpiCardProps {\n  label?: string;\n  /** Period caption, e.g. \"October 2026\". */\n  period?: string;\n  value?: number;\n  /** Value for the previous period, used for the delta. */\n  previous?: number;\n  /** Label for the previous period, e.g. \"Sep\". */\n  previousLabel?: string;\n  format?: \"currency\" | \"number\";\n  currency?: string;\n  /** Parts that add up to `value`, shown in the breakdown. */\n  segments?: KpiSegment[];\n  /** When true a drop is the good direction (e.g. churn). */\n  invert?: boolean;\n  className?: string;\n}\n\nconst focus = \"outline-none focus-visible:shadow-[var(--focus-ring)]\";\nconst shades = [\"bg-ink\", \"bg-ink/55\", \"bg-ink/25\"];\n\n/** Headline KPI that counts up on mount, shows its delta with direction and word, and springs open a stacked breakdown. */\nexport function KpiCard({\n  label = \"Net revenue\",\n  period = \"October 2026\",\n  value = 84250,\n  previous = 74960,\n  previousLabel = \"Sep\",\n  format = \"currency\",\n  currency = \"USD\",\n  segments = [\n    { label: \"Subscriptions\", value: 61400 },\n    { label: \"Add-ons\", value: 15900 },\n    { label: \"Services\", value: 6950 },\n  ],\n  invert = false,\n  className,\n}: KpiCardProps) {\n  const reduce = useReducedMotion() ?? false;\n  const [open, setOpen] = React.useState(false);\n  const panelId = React.useId();\n\n  const fmt = React.useMemo(\n    () => (format === \"currency\" ? new Intl.NumberFormat(\"en-US\", { style: \"currency\", currency, maximumFractionDigits: 0 }) : new Intl.NumberFormat(\"en-US\")),\n    [format, currency],\n  );\n  const mv = useSpring(reduce ? value : 0, { stiffness: 55, damping: 18 });\n  const text = useTransform(mv, (v) => fmt.format(Math.round(v)));\n  React.useEffect(() => {\n    if (reduce) mv.jump(value);\n    else mv.set(value);\n  }, [value, reduce, mv]);\n\n  const change = previous ? (value - previous) / previous : 0;\n  const up = change >= 0;\n  const good = invert ? !up : up;\n  const total = segments.reduce((a, s) => a + s.value, 0) || 1;\n\n  return (\n    <section aria-label={label} className={cn(\"w-full max-w-[360px] rounded-xl border border-border bg-surface-raised p-5 shadow-sm\", className)}>\n      <div className=\"flex items-center justify-between gap-3\">\n        <p className=\"text-[13px] font-medium text-ink-muted\">{label}</p>\n        <p className=\"font-mono text-[11px] text-ink-subtle\">{period}</p>\n      </div>\n      <p className=\"mt-2 font-display text-[38px] font-semibold leading-none tracking-[-1.2px] text-ink tabular-nums\">\n        <motion.span aria-hidden>{text}</motion.span>\n        <span className=\"sr-only\">{fmt.format(value)}</span>\n      </p>\n      <div className=\"mt-3 flex flex-wrap items-center gap-2\">\n        <motion.span\n          initial={reduce ? false : { opacity: 0, y: 6 }}\n          animate={{ opacity: 1, y: 0 }}\n          transition={{ type: \"spring\", stiffness: 300, damping: 24, delay: reduce ? 0 : 0.5 }}\n          className={cn(\"inline-flex items-center gap-0.5 rounded-full px-2 py-0.5 text-xs font-semibold\", good ? \"bg-success/12 text-success\" : \"bg-danger/12 text-danger\")}\n        >\n          {up ? <ArrowUpRight aria-hidden className=\"size-3.5\" /> : <ArrowDownRight aria-hidden className=\"size-3.5\" />}\n          <span className=\"sr-only\">{up ? \"Up\" : \"Down\"}</span>\n          {Math.abs(change * 100).toFixed(1)}%\n        </motion.span>\n        <span className=\"text-xs text-ink-subtle\">\n          vs {fmt.format(previous)} in {previousLabel}\n        </span>\n      </div>\n\n      <div className=\"mt-5 border-t border-border pt-3\">\n        <button\n          type=\"button\"\n          aria-expanded={open}\n          aria-controls={panelId}\n          onClick={() => setOpen((o) => !o)}\n          className={cn(\"flex w-full items-center justify-between rounded-xs py-1 text-[13px] font-medium text-ink\", focus)}\n        >\n          {open ? \"Hide breakdown\" : \"Show breakdown\"}\n          <ChevronDown aria-hidden className={cn(\"size-4 text-ink-muted transition-transform duration-300 ease-spring\", open && \"rotate-180\")} />\n        </button>\n        <AnimatePresence initial={false}>\n          {open && (\n            <motion.div\n              id={panelId}\n              initial={reduce ? { opacity: 0 } : { height: 0, opacity: 0 }}\n              animate={reduce ? { opacity: 1 } : { height: \"auto\", opacity: 1 }}\n              exit={reduce ? { opacity: 0 } : { height: 0, opacity: 0 }}\n              transition={{ type: \"spring\", stiffness: 320, damping: 32 }}\n              className=\"overflow-hidden\"\n            >\n              <div className=\"mt-3 flex h-2.5 gap-0.5 overflow-hidden rounded-full\">\n                {segments.map((s, i) => (\n                  <motion.span\n                    key={s.label}\n                    className={cn(\"h-full origin-left first:rounded-l-full last:rounded-r-full\", shades[i % shades.length])}\n                    style={{ width: `${(s.value / total) * 100}%` }}\n                    initial={reduce ? false : { scaleX: 0 }}\n                    animate={{ scaleX: 1 }}\n                    transition={{ type: \"spring\", stiffness: 160, damping: 22, delay: reduce ? 0 : 0.08 + i * 0.08 }}\n                  />\n                ))}\n              </div>\n              <ul className=\"mt-3 space-y-1.5 pb-1\">\n                {segments.map((s, i) => (\n                  <li key={s.label} className=\"flex items-center gap-2 text-[13px]\">\n                    <span aria-hidden className={cn(\"size-2 rounded-full\", shades[i % shades.length])} />\n                    <span className=\"flex-1 text-ink-muted\">{s.label}</span>\n                    <span className=\"font-medium tabular-nums text-ink\">{fmt.format(s.value)}</span>\n                    <span className=\"w-10 text-right font-mono text-[11px] text-ink-subtle\">{Math.round((s.value / total) * 100)}%</span>\n                  </li>\n                ))}\n              </ul>\n            </motion.div>\n          )}\n        </AnimatePresence>\n      </div>\n    </section>\n  );\n}\n"},{"path":"registry/geturui/examples/stat-cards/sparkline.tsx","type":"registry:component","target":"components/examples/stat-cards/sparkline.tsx","content":"\"use client\";\n\nimport * as React from \"react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { TrendingDown, TrendingUp } from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface SparkSeries {\n  /** Tab label, e.g. \"7D\". */\n  key: string;\n  /** Word used in the caption, e.g. \"past 7 days\". */\n  caption: string;\n  points: number[];\n  /** Label per point, same length as points. */\n  labels: string[];\n}\n\nexport interface SparklineStatCardProps {\n  label?: string;\n  unit?: string;\n  series?: SparkSeries[];\n  defaultKey?: string;\n  className?: string;\n}\n\nconst DAYS = [\"Sep 26\", \"Sep 27\", \"Sep 28\", \"Sep 29\", \"Sep 30\", \"Oct 1\", \"Oct 2\"];\nconst WEEKS = [\"Aug 7\", \"Aug 14\", \"Aug 21\", \"Aug 28\", \"Sep 4\", \"Sep 11\", \"Sep 18\", \"Sep 25\", \"Oct 2\"];\nconst MONTHS = [\"Nov\", \"Dec\", \"Jan\", \"Feb\", \"Mar\", \"Apr\", \"May\", \"Jun\", \"Jul\", \"Aug\", \"Sep\", \"Oct\"];\n\nconst DEFAULT_SERIES: SparkSeries[] = [\n  { key: \"7D\", caption: \"past 7 days\", points: [1840, 2120, 1980, 2410, 2290, 2630, 2754], labels: DAYS },\n  { key: \"60D\", caption: \"past 60 days\", points: [12400, 13150, 12890, 14020, 13700, 14980, 15420, 15110, 16240], labels: WEEKS },\n  { key: \"1Y\", caption: \"past year\", points: [38e3, 41e3, 39.5e3, 44e3, 47e3, 46e3, 51e3, 55e3, 53e3, 58e3, 61e3, 64.8e3], labels: MONTHS },\n];\n\nconst focus = \"outline-none focus-visible:shadow-[var(--focus-ring)]\";\nconst W = 300;\nconst H = 72;\n\nfunction coords(points: number[]) {\n  const min = Math.min(...points);\n  const span = Math.max(...points) - min || 1;\n  return points.map((p, i) => ({ x: (i / (points.length - 1)) * W, y: H - 6 - ((p - min) / span) * (H - 14) }));\n}\n\n/** Stat with a scrubbable sparkline: period tabs redraw the line, and pointer or arrow keys read out any point. */\nexport function SparklineStatCard({ label = \"Active users\", unit = \"\", series = DEFAULT_SERIES, defaultKey, className }: SparklineStatCardProps) {\n  const reduce = useReducedMotion() ?? false;\n  const baseId = React.useId();\n  const [key, setKey] = React.useState(defaultKey ?? series[0]?.key);\n  const [hover, setHover] = React.useState<number | null>(null);\n  const s = series.find((x) => x.key === key) ?? series[0];\n  const pts = coords(s.points);\n  const d = pts.map((p, i) => `${i ? \"L\" : \"M\"}${p.x.toFixed(1)} ${p.y.toFixed(1)}`).join(\" \");\n  const idx = hover ?? s.points.length - 1;\n  const value = s.points[idx];\n  const change = (s.points[s.points.length - 1] - s.points[0]) / s.points[0];\n  const up = change >= 0;\n  const fmt = (n: number) => n.toLocaleString(\"en-US\") + unit;\n\n  function onMove(e: React.PointerEvent<SVGSVGElement>) {\n    const r = e.currentTarget.getBoundingClientRect();\n    const t = Math.min(1, Math.max(0, (e.clientX - r.left) / r.width));\n    setHover(Math.round(t * (s.points.length - 1)));\n  }\n\n  function onKey(e: React.KeyboardEvent) {\n    if (e.key !== \"ArrowLeft\" && e.key !== \"ArrowRight\") return;\n    e.preventDefault();\n    const d2 = e.key === \"ArrowRight\" ? 1 : -1;\n    setHover(Math.min(s.points.length - 1, Math.max(0, idx + d2)));\n  }\n\n  return (\n    <section aria-label={label} className={cn(\"w-full max-w-[360px] rounded-xl border border-border bg-surface-raised p-5 shadow-sm\", className)}>\n      <div className=\"flex items-start justify-between gap-3\">\n        <div className=\"min-w-0\">\n          <p className=\"text-[13px] font-medium text-ink-muted\">{label}</p>\n          <div className=\"relative mt-1.5 h-9\">\n            <AnimatePresence mode=\"popLayout\" initial={false}>\n              <motion.p\n                key={`${s.key}-${idx}`}\n                initial={{ opacity: 0, filter: \"blur(6px)\", y: 4 }}\n                animate={{ opacity: 1, filter: \"blur(0px)\", y: 0 }}\n                exit={{ opacity: 0, filter: \"blur(6px)\", y: -4 }}\n                transition={{ duration: 0.18 }}\n                className=\"font-display text-[32px] font-semibold leading-9 tracking-[-1px] text-ink tabular-nums\"\n              >\n                {fmt(value)}\n              </motion.p>\n            </AnimatePresence>\n          </div>\n          <p className=\"mt-1 text-xs text-ink-subtle\" aria-live=\"polite\">\n            {hover === null ? (\n              <span className={cn(\"inline-flex items-center gap-1 font-medium\", up ? \"text-success\" : \"text-danger\")}>\n                {up ? <TrendingUp aria-hidden className=\"size-3.5\" /> : <TrendingDown aria-hidden className=\"size-3.5\" />}\n                {up ? \"Up\" : \"Down\"} {Math.abs(change * 100).toFixed(1)}%<span className=\"font-normal text-ink-subtle\">{s.caption}</span>\n              </span>\n            ) : (\n              <span>{s.labels[idx]}</span>\n            )}\n          </p>\n        </div>\n        <div role=\"group\" aria-label=\"Period\" className=\"flex shrink-0 gap-0.5 rounded-full bg-surface-sunken p-0.5\">\n          {series.map((x) => {\n            const on = x.key === s.key;\n            return (\n              <button\n                key={x.key}\n                type=\"button\"\n                aria-pressed={on}\n                onClick={() => {\n                  setKey(x.key);\n                  setHover(null);\n                }}\n                className={cn(\"relative h-7 rounded-full px-2.5 font-mono text-[11px] transition-colors\", on ? \"text-ink\" : \"text-ink-subtle hover:text-ink\", focus)}\n              >\n                {on && <motion.span layoutId={`${baseId}-tab`} transition={reduce ? { duration: 0 } : { type: \"spring\", stiffness: 420, damping: 32 }} className=\"absolute inset-0 rounded-full bg-surface-raised shadow-sm\" />}\n                <span className=\"relative\">{x.key}</span>\n              </button>\n            );\n          })}\n        </div>\n      </div>\n\n      <svg\n        viewBox={`0 0 ${W} ${H}`}\n        preserveAspectRatio=\"none\"\n        role=\"slider\"\n        tabIndex={0}\n        aria-label={`${label} over the ${s.caption}`}\n        aria-valuemin={0}\n        aria-valuemax={s.points.length - 1}\n        aria-valuenow={idx}\n        aria-valuetext={`${s.labels[idx]}: ${fmt(value)}`}\n        onPointerMove={onMove}\n        onPointerLeave={() => setHover(null)}\n        onKeyDown={onKey}\n        onBlur={() => setHover(null)}\n        className={cn(\"mt-4 h-[72px] w-full touch-none overflow-visible rounded-xs text-ink\", focus)}\n      >\n        <defs>\n          <linearGradient id={`${baseId}-g`} x1=\"0\" x2=\"0\" y1=\"0\" y2=\"1\">\n            <stop offset=\"0%\" stopColor=\"currentColor\" stopOpacity={0.14} />\n            <stop offset=\"100%\" stopColor=\"currentColor\" stopOpacity={0} />\n          </linearGradient>\n        </defs>\n        <AnimatePresence mode=\"popLayout\" initial={false}>\n          <motion.g key={s.key} initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0, transition: { duration: 0.12 } }}>\n            <path d={`${d} L${W} ${H} L0 ${H} Z`} fill={`url(#${baseId}-g)`} />\n            <motion.path\n              d={d}\n              fill=\"none\"\n              stroke=\"currentColor\"\n              strokeWidth={2}\n              strokeLinejoin=\"round\"\n              strokeLinecap=\"round\"\n              vectorEffect=\"non-scaling-stroke\"\n              initial={{ pathLength: reduce ? 1 : 0 }}\n              animate={{ pathLength: 1 }}\n              transition={{ duration: 0.8, ease: [0.22, 1, 0.36, 1] }}\n            />\n          </motion.g>\n        </AnimatePresence>\n        <line x1={pts[idx].x} x2={pts[idx].x} y1={0} y2={H} stroke=\"currentColor\" strokeOpacity={hover === null ? 0 : 0.18} strokeDasharray=\"3 3\" vectorEffect=\"non-scaling-stroke\" />\n      </svg>\n      <div className=\"pointer-events-none relative -mt-[72px] h-[72px]\" aria-hidden>\n        <motion.span\n          className=\"absolute size-3 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-surface-raised bg-volt shadow-sm\"\n          animate={{ left: `${(pts[idx].x / W) * 100}%`, top: pts[idx].y }}\n          transition={reduce ? { duration: 0 } : { type: \"spring\", stiffness: 500, damping: 36 }}\n        />\n      </div>\n      <div className=\"mt-2 flex justify-between font-mono text-[10.5px] text-ink-subtle\">\n        <span>{s.labels[0]}</span>\n        <span>{s.labels[s.labels.length - 1]}</span>\n      </div>\n    </section>\n  );\n}\n"},{"path":"registry/geturui/examples/stat-cards/goal.tsx","type":"registry:component","target":"components/examples/stat-cards/goal.tsx","content":"\"use client\";\n\nimport * as React from \"react\";\nimport { AnimatePresence, motion, useReducedMotion, useSpring, useTransform } from \"motion/react\";\nimport { Check, Minus, Plus, Target } from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface GoalRingCardProps {\n  label?: string;\n  /** What one step counts, e.g. \"demos booked\". */\n  unit?: string;\n  defaultValue?: number;\n  goal?: number;\n  /** Deadline caption, e.g. \"Ends Oct 31\". */\n  deadline?: string;\n  /** Checkpoints shown under the ring, as fractions of the goal. */\n  milestones?: number[];\n  onChange?: (value: number) => void;\n  className?: string;\n}\n\nconst focus = \"outline-none focus-visible:shadow-[var(--focus-ring)]\";\nconst R = 52;\nconst C = 2 * Math.PI * R;\n\n/** Goal progress ring: the arc and percentage spring to each new value, milestones tick off, and hitting the goal swaps in a done state. */\nexport function GoalRingCard({\n  label = \"Q4 sales goal\",\n  unit = \"demos booked\",\n  defaultValue = 34,\n  goal = 48,\n  deadline = \"Ends Dec 31\",\n  milestones = [0.25, 0.5, 0.75, 1],\n  onChange,\n  className,\n}: GoalRingCardProps) {\n  const reduce = useReducedMotion() ?? false;\n  const [value, setValue] = React.useState(defaultValue);\n  const pct = Math.min(1, value / goal);\n  const done = value >= goal;\n\n  const mv = useSpring(reduce ? pct : 0, { stiffness: 90, damping: 20 });\n  const offset = useTransform(mv, (v) => C * (1 - v));\n  const label100 = useTransform(mv, (v) => `${Math.round(v * 100)}%`);\n  React.useEffect(() => {\n    if (reduce) mv.jump(pct);\n    else mv.set(pct);\n  }, [pct, reduce, mv]);\n\n  function set(n: number) {\n    const next = Math.max(0, Math.min(goal, n));\n    setValue(next);\n    onChange?.(next);\n  }\n\n  return (\n    <section aria-label={label} className={cn(\"w-full max-w-[340px] rounded-xl border border-border bg-surface-raised p-5 shadow-sm\", className)}>\n      <div className=\"flex items-center justify-between\">\n        <p className=\"inline-flex items-center gap-1.5 text-[13px] font-medium text-ink-muted\">\n          <Target aria-hidden className=\"size-4\" />\n          {label}\n        </p>\n        <span className=\"font-mono text-[11px] text-ink-subtle\">{deadline}</span>\n      </div>\n\n      <div className=\"mt-4 flex items-center gap-5\">\n        <div className=\"relative size-[132px] shrink-0\">\n          <svg viewBox=\"0 0 132 132\" className=\"size-full -rotate-90\" role=\"progressbar\" aria-label={`${label} progress`} aria-valuemin={0} aria-valuemax={goal} aria-valuenow={value} aria-valuetext={`${value} of ${goal} ${unit}`}>\n            <circle cx=\"66\" cy=\"66\" r={R} fill=\"none\" strokeWidth=\"12\" className=\"stroke-surface-sunken\" />\n            <motion.circle cx=\"66\" cy=\"66\" r={R} fill=\"none\" strokeWidth=\"12\" strokeLinecap=\"round\" strokeDasharray={C} style={{ strokeDashoffset: offset }} className=\"stroke-volt\" />\n            {milestones.slice(0, -1).map((m) => {\n              const a = m * 2 * Math.PI;\n              return <circle key={m} cx={66 + R * Math.cos(a)} cy={66 + R * Math.sin(a)} r=\"2\" className={cn(\"transition-colors\", pct >= m ? \"fill-on-volt/40\" : \"fill-ink-subtle/50\")} />;\n            })}\n          </svg>\n          <div className=\"absolute inset-0 grid place-items-center text-center\">\n            <AnimatePresence mode=\"popLayout\" initial={false}>\n              {done ? (\n                <motion.span\n                  key=\"done\"\n                  initial={reduce ? { opacity: 0 } : { scale: 0.3, opacity: 0 }}\n                  animate={{ scale: 1, opacity: 1 }}\n                  exit={{ opacity: 0 }}\n                  transition={{ type: \"spring\", stiffness: 420, damping: 16 }}\n                  className=\"grid size-12 place-items-center rounded-full bg-volt text-on-volt\"\n                >\n                  <Check aria-hidden className=\"size-6\" strokeWidth={3} />\n                </motion.span>\n              ) : (\n                <motion.span key=\"pct\" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className=\"font-display text-[28px] font-semibold tracking-[-0.8px] text-ink tabular-nums\">\n                  <motion.span>{label100}</motion.span>\n                </motion.span>\n              )}\n            </AnimatePresence>\n          </div>\n        </div>\n\n        <div className=\"min-w-0\">\n          <p className=\"font-display text-[28px] font-semibold leading-none tracking-[-0.8px] text-ink tabular-nums\">\n            {value}\n            <span className=\"text-base font-medium text-ink-subtle\"> / {goal}</span>\n          </p>\n          <p className=\"mt-1 text-xs text-ink-muted\">{unit}</p>\n          <AnimatePresence mode=\"popLayout\" initial={false}>\n            <motion.p\n              key={done ? \"d\" : \"r\"}\n              initial={{ opacity: 0, filter: \"blur(6px)\" }}\n              animate={{ opacity: 1, filter: \"blur(0px)\" }}\n              exit={{ opacity: 0, filter: \"blur(6px)\" }}\n              className={cn(\"mt-3 text-xs font-medium\", done ? \"text-volt-ink\" : \"text-ink\")}\n            >\n              {done ? \"Goal reached\" : `${goal - value} to go`}\n            </motion.p>\n          </AnimatePresence>\n        </div>\n      </div>\n\n      <ol aria-label=\"Milestones\" className=\"mt-5 grid grid-cols-4 gap-1.5\">\n        {milestones.map((m) => {\n          const hit = pct >= m;\n          return (\n            <li key={m} className=\"text-center\">\n              <span className=\"block h-1 overflow-hidden rounded-full bg-surface-sunken\">\n                <motion.span className=\"block h-full origin-left rounded-full bg-ink\" animate={{ scaleX: hit ? 1 : 0 }} transition={{ type: \"spring\", stiffness: 260, damping: 28 }} />\n              </span>\n              <span className={cn(\"mt-1 block font-mono text-[10.5px]\", hit ? \"text-ink\" : \"text-ink-subtle\")}>\n                {Math.round(m * goal)}\n                <span className=\"sr-only\">{hit ? \" reached\" : \" not reached\"}</span>\n              </span>\n            </li>\n          );\n        })}\n      </ol>\n\n      <div className=\"mt-4 flex items-center gap-2 border-t border-border pt-4\">\n        <motion.button type=\"button\" aria-label=\"Remove one\" whileTap={reduce ? undefined : { scale: 0.92 }} onClick={() => set(value - 1)} disabled={value <= 0} className={cn(\"grid size-9 place-items-center rounded-full border border-border-strong/40 text-ink hover:bg-surface-hover disabled:opacity-40\", focus)}>\n          <Minus aria-hidden className=\"size-4\" />\n        </motion.button>\n        <motion.button type=\"button\" whileTap={reduce ? undefined : { scale: 0.96 }} onClick={() => set(value + 1)} disabled={done} className={cn(\"inline-flex h-9 flex-1 items-center justify-center gap-1.5 rounded-full bg-volt text-[13px] font-semibold text-on-volt disabled:cursor-not-allowed disabled:bg-surface-sunken disabled:text-ink-subtle\", focus)}>\n          <Plus aria-hidden className=\"size-4\" />\n          Log one\n        </motion.button>\n      </div>\n    </section>\n  );\n}\n"},{"path":"registry/geturui/examples/stat-cards/comparison.tsx","type":"registry:component","target":"components/examples/stat-cards/comparison.tsx","content":"\"use client\";\n\nimport * as React from \"react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { ArrowDownRight, ArrowUpRight } from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface ComparisonMetric {\n  key: string;\n  label: string;\n  current: number;\n  previous: number;\n  /** Prefix such as \"$\". */\n  prefix?: string;\n  suffix?: string;\n  /** A drop is good (e.g. refund rate). */\n  invert?: boolean;\n}\n\nexport interface ComparisonStatCardProps {\n  title?: string;\n  currentLabel?: string;\n  previousLabel?: string;\n  metrics?: ComparisonMetric[];\n  defaultKey?: string;\n  className?: string;\n}\n\nconst DEFAULT_METRICS: ComparisonMetric[] = [\n  { key: \"revenue\", label: \"Revenue\", current: 48210, previous: 39870, prefix: \"$\" },\n  { key: \"orders\", label: \"Orders\", current: 1284, previous: 1342 },\n  { key: \"aov\", label: \"Avg. order\", current: 37.55, previous: 29.71, prefix: \"$\" },\n  { key: \"refunds\", label: \"Refunds\", current: 1.8, previous: 2.6, suffix: \"%\", invert: true },\n];\n\nconst focus = \"outline-none focus-visible:shadow-[var(--focus-ring)]\";\n\nfunction format(m: ComparisonMetric, n: number) {\n  const digits = n % 1 ? 2 : 0;\n  return `${m.prefix ?? \"\"}${n.toLocaleString(\"en-US\", { minimumFractionDigits: digits, maximumFractionDigits: digits })}${m.suffix ?? \"\"}`;\n}\n\n/** Two-period comparison: pick a metric and both bars spring to their new lengths while the delta crossfades. */\nexport function ComparisonStatCard({\n  title = \"This month vs last\",\n  currentLabel = \"Oct 1 – 31\",\n  previousLabel = \"Sep 1 – 30\",\n  metrics = DEFAULT_METRICS,\n  defaultKey,\n  className,\n}: ComparisonStatCardProps) {\n  const reduce = useReducedMotion() ?? false;\n  const baseId = React.useId();\n  const [key, setKey] = React.useState(defaultKey ?? metrics[0]?.key);\n  const m = metrics.find((x) => x.key === key) ?? metrics[0];\n  const max = Math.max(m.current, m.previous) || 1;\n  const change = m.previous ? (m.current - m.previous) / m.previous : 0;\n  const up = change >= 0;\n  const good = m.invert ? !up : up;\n  const spring = reduce ? { duration: 0 } : { type: \"spring\" as const, stiffness: 170, damping: 22 };\n\n  const rows = [\n    { label: currentLabel, value: m.current, bar: \"bg-volt\", text: \"text-ink\" },\n    { label: previousLabel, value: m.previous, bar: \"bg-ink-subtle/45\", text: \"text-ink-muted\" },\n  ];\n\n  return (\n    <section aria-label={title} className={cn(\"w-full max-w-[400px] rounded-xl border border-border bg-surface-raised p-5 shadow-sm\", className)}>\n      <div className=\"flex items-center justify-between gap-3\">\n        <h3 className=\"text-[13px] font-medium text-ink-muted\">{title}</h3>\n        <AnimatePresence mode=\"popLayout\" initial={false}>\n          <motion.span\n            key={m.key}\n            initial={{ opacity: 0, filter: \"blur(6px)\", scale: 0.9 }}\n            animate={{ opacity: 1, filter: \"blur(0px)\", scale: 1 }}\n            exit={{ opacity: 0, filter: \"blur(6px)\", scale: 0.9 }}\n            transition={{ duration: 0.2 }}\n            className={cn(\"inline-flex items-center gap-0.5 rounded-full px-2 py-0.5 text-xs font-semibold\", good ? \"bg-success/12 text-success\" : \"bg-danger/12 text-danger\")}\n          >\n            {up ? <ArrowUpRight aria-hidden className=\"size-3.5\" /> : <ArrowDownRight aria-hidden className=\"size-3.5\" />}\n            {up ? \"+\" : \"−\"}\n            {Math.abs(change * 100).toFixed(1)}%<span className=\"sr-only\">{good ? \", better\" : \", worse\"}</span>\n          </motion.span>\n        </AnimatePresence>\n      </div>\n\n      <div role=\"tablist\" aria-label=\"Metric\" className=\"mt-3 flex gap-1 overflow-x-auto [scrollbar-width:none]\">\n        {metrics.map((x) => {\n          const on = x.key === m.key;\n          return (\n            <button\n              key={x.key}\n              type=\"button\"\n              role=\"tab\"\n              aria-selected={on}\n              aria-controls={`${baseId}-panel`}\n              onClick={() => setKey(x.key)}\n              onKeyDown={(e) => {\n                if (e.key !== \"ArrowRight\" && e.key !== \"ArrowLeft\") return;\n                const i = metrics.findIndex((y) => y.key === m.key);\n                const next = metrics[(i + (e.key === \"ArrowRight\" ? 1 : -1) + metrics.length) % metrics.length];\n                setKey(next.key);\n                (e.currentTarget.parentElement?.querySelectorAll(\"button\")[metrics.indexOf(next)] as HTMLButtonElement | undefined)?.focus();\n              }}\n              tabIndex={on ? 0 : -1}\n              className={cn(\"relative h-8 shrink-0 rounded-full px-3 text-[12.5px] font-medium transition-colors\", on ? \"text-bg\" : \"text-ink-muted hover:text-ink\", focus)}\n            >\n              {on && <motion.span layoutId={`${baseId}-m`} transition={reduce ? { duration: 0 } : { type: \"spring\", stiffness: 420, damping: 32 }} className=\"absolute inset-0 rounded-full bg-ink\" />}\n              <span className=\"relative\">{x.label}</span>\n            </button>\n          );\n        })}\n      </div>\n\n      <div id={`${baseId}-panel`} role=\"tabpanel\" aria-label={m.label} className=\"mt-5 space-y-4\">\n        {rows.map((r) => (\n          <div key={r.label}>\n            <div className=\"flex items-baseline justify-between gap-3\">\n              <span className=\"text-xs text-ink-subtle\">{r.label}</span>\n              <AnimatePresence mode=\"popLayout\" initial={false}>\n                <motion.span\n                  key={`${m.key}-${r.value}`}\n                  initial={{ opacity: 0, y: 6 }}\n                  animate={{ opacity: 1, y: 0 }}\n                  exit={{ opacity: 0, y: -6 }}\n                  transition={{ duration: 0.2 }}\n                  className={cn(\"font-display text-xl font-semibold tracking-[-0.4px] tabular-nums\", r.text)}\n                >\n                  {format(m, r.value)}\n                </motion.span>\n              </AnimatePresence>\n            </div>\n            <div className=\"mt-1.5 h-3 overflow-hidden rounded-full bg-surface-sunken\">\n              <motion.div className={cn(\"h-full origin-left rounded-full\", r.bar)} initial={reduce ? false : { scaleX: 0 }} animate={{ scaleX: r.value / max }} transition={spring} />\n            </div>\n          </div>\n        ))}\n      </div>\n      <p className=\"mt-4 text-xs text-ink-subtle\">\n        {m.label} {up ? \"rose\" : \"fell\"} by {format(m, Math.abs(Math.round((m.current - m.previous) * 100) / 100))} against the previous period.\n      </p>\n    </section>\n  );\n}\n"},{"path":"LICENSE-geturui.md","type":"registry:file","target":"LICENSE-geturui.md","content":"MIT License\n\nCopyright (c) 2026 geturui.com\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"}],"categories":["examples","stats","kpi","metric","dashboard","sparkline","progress","goal","comparison","analytics"],"meta":{"tier":"free","license":"MIT","docs":"https://geturui.com/docs/stat-cards"}}