{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"sparkline-stats","type":"registry:component","title":"Sparkline Stats","description":"KPI cards with rolling numbers, an up/down delta badge and a sparkline that draws in, with a crosshair on hover or arrow keys.","author":"GetUrUI <hello@geturui.dev>","dependencies":["motion","lucide-react"],"registryDependencies":["@geturui/number-ticker"],"files":[{"path":"registry/geturui/charts/sparkline-stats.tsx","type":"registry:component","target":"components/charts/sparkline-stats.tsx","content":"\"use client\";\n\nimport * as React from \"react\";\nimport { motion, useInView } from \"motion/react\";\nimport { TrendingDown, TrendingUp } from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\nimport { NumberTicker } from \"@/components/ui/number-ticker\";\n\nconst REDUCE = \"(prefers-reduced-motion: reduce)\";\nconst subscribeReduce = (cb: () => void) => {\n  const m = window.matchMedia(REDUCE);\n  m.addEventListener(\"change\", cb);\n  return () => m.removeEventListener(\"change\", cb);\n};\n/** Reduced-motion preference that is false on the server and during hydration, so the first render matches. */\nfunction useReducedMotionSafe() {\n  return React.useSyncExternalStore(subscribeReduce, () => window.matchMedia(REDUCE).matches, () => false);\n}\n\nexport interface SparklineProps {\n  data: number[];\n  /** Labels for each point, shown under the crosshair. */\n  labels?: string[];\n  width?: number;\n  height?: number;\n  /** Stroke colour; any CSS colour. */\n  color?: string;\n  formatValue?: (n: number) => string;\n  /** Accessible name for the sparkline. */\n  label?: string;\n  className?: string;\n}\n\n/** Sparkline that draws in, fills a soft gradient and shows a crosshair on hover or arrow keys. */\nexport function Sparkline({\n  data,\n  labels,\n  width = 240,\n  height = 56,\n  color = \"var(--volt-ink)\",\n  formatValue = (n) => n.toLocaleString(\"en-US\"),\n  label = \"Trend\",\n  className,\n}: SparklineProps) {\n  const reduce = useReducedMotionSafe();\n  const ref = React.useRef<SVGSVGElement>(null);\n  const inView = useInView(ref, { once: true, margin: \"-20px\" });\n  const [hover, setHover] = React.useState<number | null>(null);\n  const gid = React.useId().replace(/[^a-zA-Z0-9_-]/g, \"\");\n\n  const pad = 4;\n  const min = Math.min(...data);\n  const max = Math.max(...data);\n  const span = max - min || 1;\n  const pts = data.map((v, i) => [\n    pad + (i * (width - pad * 2)) / Math.max(1, data.length - 1),\n    pad + (1 - (v - min) / span) * (height - pad * 2),\n  ]);\n  const line = pts.map(([x, y], i) => `${i ? \"L\" : \"M\"}${x.toFixed(2)},${y.toFixed(2)}`).join(\"\");\n  const area = `${line}L${pts[pts.length - 1]?.[0] ?? 0},${height}L${pts[0]?.[0] ?? 0},${height}Z`;\n\n  function onMove(e: React.PointerEvent<SVGSVGElement>) {\n    const r = e.currentTarget.getBoundingClientRect();\n    const x = ((e.clientX - r.left) / r.width) * width;\n    const i = Math.round(((x - pad) / (width - pad * 2)) * (data.length - 1));\n    setHover(Math.max(0, Math.min(data.length - 1, i)));\n  }\n\n  function onKey(e: React.KeyboardEvent) {\n    if (e.key === \"ArrowRight\" || e.key === \"ArrowLeft\") {\n      e.preventDefault();\n      setHover((h) => {\n        const cur = h ?? data.length - 1;\n        return Math.max(0, Math.min(data.length - 1, cur + (e.key === \"ArrowRight\" ? 1 : -1)));\n      });\n    } else if (e.key === \"Home\" || e.key === \"End\") {\n      e.preventDefault();\n      setHover(e.key === \"Home\" ? 0 : data.length - 1);\n    } else if (e.key === \"Escape\") setHover(null);\n  }\n\n  const hp = hover !== null ? pts[hover] : null;\n\n  return (\n    <div className={cn(\"relative\", className)}>\n      <svg\n        ref={ref}\n        viewBox={`0 0 ${width} ${height}`}\n        preserveAspectRatio=\"none\"\n        className=\"block h-auto w-full touch-none overflow-visible outline-none focus-visible:shadow-[var(--focus-ring)] rounded-[6px]\"\n        style={{ aspectRatio: `${width} / ${height}` }}\n        tabIndex={0}\n        role=\"img\"\n        aria-label={`${label}: ${data.length} points from ${formatValue(data[0] ?? 0)} to ${formatValue(data[data.length - 1] ?? 0)}`}\n        onPointerMove={onMove}\n        onPointerLeave={() => setHover(null)}\n        onBlur={() => setHover(null)}\n        onKeyDown={onKey}\n      >\n        <defs>\n          <linearGradient id={`${gid}-fill`} x1=\"0\" x2=\"0\" y1=\"0\" y2=\"1\">\n            <stop offset=\"0%\" style={{ stopColor: color, stopOpacity: 0.28 }} />\n            <stop offset=\"100%\" style={{ stopColor: color, stopOpacity: 0 }} />\n          </linearGradient>\n        </defs>\n        <motion.path\n          d={area}\n          fill={`url(#${gid}-fill)`}\n          initial={{ opacity: 0 }}\n          animate={inView ? { opacity: 1 } : undefined}\n          transition={{ duration: 0.6, delay: reduce ? 0 : 0.5 }}\n        />\n        <motion.path\n          d={line}\n          fill=\"none\"\n          style={{ stroke: color }}\n          strokeWidth={1.75}\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          initial={{ pathLength: reduce ? 1 : 0 }}\n          animate={inView ? { pathLength: 1 } : undefined}\n          transition={{ duration: reduce ? 0 : 1.1, ease: [0.65, 0, 0.35, 1] }}\n        />\n        {hp && (\n          <g pointerEvents=\"none\">\n            <line x1={hp[0]} x2={hp[0]} y1={0} y2={height} className=\"stroke-ink-subtle\" strokeDasharray=\"2 3\" vectorEffect=\"non-scaling-stroke\" />\n          </g>\n        )}\n      </svg>\n      {hp && hover !== null && (\n        <>\n          <span\n            aria-hidden\n            className=\"pointer-events-none absolute size-2.5 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-surface-raised\"\n            style={{ left: `${(hp[0] / width) * 100}%`, top: `${(hp[1] / height) * 100}%`, background: color }}\n          />\n          <span\n            role=\"status\"\n            className={cn(\n              \"pointer-events-none absolute -top-7 whitespace-nowrap rounded-[6px] bg-ink px-1.5 py-0.5 font-mono text-[10.5px] text-bg tabular-nums\",\n              hp[0] / width > 0.7 ? \"-translate-x-full\" : hp[0] / width < 0.3 ? \"\" : \"-translate-x-1/2\",\n            )}\n            style={{ left: `${(hp[0] / width) * 100}%` }}\n          >\n            {labels?.[hover] ? `${labels[hover]} · ` : \"\"}\n            {formatValue(data[hover])}\n          </span>\n        </>\n      )}\n    </div>\n  );\n}\n\nexport interface StatItem {\n  label: string;\n  value: number;\n  prefix?: string;\n  suffix?: string;\n  decimals?: number;\n  /** Change vs. the previous period, in percent (12.4 = +12.4%). */\n  delta: number;\n  /** Lower is better (e.g. churn, latency): flips the delta colour. */\n  invert?: boolean;\n  data: number[];\n  labels?: string[];\n  /** Small caption under the value, e.g. \"vs. last 30 days\". */\n  caption?: string;\n}\n\nexport interface StatCardProps extends StatItem {\n  className?: string;\n}\n\n/** KPI card: rolling value, delta badge with icon and word, and a sparkline with a hover crosshair. */\nexport function StatCard({ label, value, prefix, suffix, decimals = 0, delta, invert, data, labels, caption, className }: StatCardProps) {\n  const up = delta >= 0;\n  const good = invert ? !up : up;\n  const fmt = (n: number) =>\n    `${prefix ?? \"\"}${n.toLocaleString(\"en-US\", { minimumFractionDigits: decimals, maximumFractionDigits: decimals })}${suffix ?? \"\"}`;\n  return (\n    <motion.div\n      initial={{ opacity: 0, y: 10 }}\n      whileInView={{ opacity: 1, y: 0 }}\n      viewport={{ once: true }}\n      transition={{ type: \"spring\", stiffness: 300, damping: 30 }}\n      className={cn(\"flex min-w-0 flex-col rounded-lg border border-border bg-surface-raised p-4 shadow-sm\", className)}\n    >\n      <div className=\"flex items-start justify-between gap-2\">\n        <p className=\"text-[12.5px] font-medium text-ink-muted\">{label}</p>\n        <span\n          className={cn(\n            \"inline-flex h-5 shrink-0 items-center gap-1 rounded-full px-1.5 text-[11px] font-semibold tabular-nums\",\n            good ? \"bg-volt-soft text-success\" : \"bg-surface text-danger ring-1 ring-danger/30\",\n          )}\n        >\n          {up ? <TrendingUp aria-hidden className=\"size-3\" strokeWidth={2.5} /> : <TrendingDown aria-hidden className=\"size-3\" strokeWidth={2.5} />}\n          {up ? \"Up\" : \"Down\"} {Math.abs(delta).toFixed(1)}%\n        </span>\n      </div>\n      <NumberTicker\n        value={value}\n        prefix={prefix}\n        suffix={suffix}\n        decimals={decimals}\n        className=\"mt-2 font-display text-[28px] font-semibold tracking-[-0.6px] text-ink\"\n      />\n      {caption && <p className=\"mt-1 text-[11.5px] text-ink-subtle\">{caption}</p>}\n      <Sparkline\n        data={data}\n        labels={labels}\n        label={`${label} trend`}\n        formatValue={fmt}\n        color={good ? \"var(--volt-ink)\" : \"var(--danger)\"}\n        className=\"mt-4\"\n      />\n    </motion.div>\n  );\n}\n\nexport interface SparklineStatsProps {\n  stats: StatItem[];\n  className?: string;\n}\n\n/** Responsive grid of KPI cards with rolling numbers, delta badges and sparklines that draw in. */\nexport function SparklineStats({ stats, className }: SparklineStatsProps) {\n  return (\n    <div className={cn(\"grid w-full grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3\", className)}>\n      {stats.map((s) => (\n        <StatCard key={s.label} {...s} />\n      ))}\n    </div>\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":["charts","chart","kpi","stats","sparkline","dashboard","metrics"],"meta":{"tier":"free","license":"MIT","docs":"https://geturui.com/docs/sparkline-stats"}}