{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "stat-tile",
  "type": "registry:ui",
  "title": "Stat Tile",
  "description": "KPI card whose sparkline you scrub to roll the headline to any day.",
  "categories": [
    "data"
  ],
  "dependencies": [
    "@base-ui/react",
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/icons.json",
    "https://www.stealth.pm/r/motion.json",
    "https://www.stealth.pm/r/use-controllable-state.json",
    "https://www.stealth.pm/r/delta-badge.json",
    "https://www.stealth.pm/r/number-ticker.json"
  ],
  "files": [
    {
      "path": "components/ui/stat-tile.tsx",
      "content": "\"use client\";\nimport { Radio } from \"@base-ui/react/radio\";\nimport { RadioGroup } from \"@base-ui/react/radio-group\";\nimport { Tooltip } from \"@base-ui/react/tooltip\";\nimport { motion, useReducedMotion } from \"motion/react\";\nimport { useEffect, useId, useRef, useState } from \"react\";\nimport { DeltaBadge, getDelta } from \"@/components/ui/delta-badge\";\nimport { NumberTicker, numberFormatOptions, useLocale, type NumberFormatShortcuts } from \"@/components/ui/number-ticker\";\nimport { cn } from \"@/lib/cn\";\nimport { Refresh } from \"@/lib/icons\";\nimport { ease, spring } from \"@/lib/motion\";\nimport { useControllableState } from \"@/lib/use-controllable-state\";\n\nexport type StatPoint = { label: string; value: number };\nexport type StatPeriod = { value: string; label: string };\n\n// Every series is resampled to the same number of points, so switching periods\n// morphs one line into the next instead of redrawing it.\nconst SAMPLES = 64;\nconst W = 320;\nconst H = 56;\n\nfunction resample(values: number[], n = SAMPLES) {\n  if (values.length === 0) return [];\n  if (values.length === 1) return Array.from({ length: n }, () => values[0]);\n  return Array.from({ length: n }, (_, i) => {\n    const t = (i / (n - 1)) * (values.length - 1);\n    const lo = Math.floor(t);\n    const hi = Math.min(values.length - 1, lo + 1);\n    return values[lo] + (values[hi] - values[lo]) * (t - lo);\n  });\n}\n\nfunction geometry(values: number[]) {\n  const pts = resample(values);\n  const min = Math.min(...pts);\n  const max = Math.max(...pts);\n  const span = max - min || 1;\n  // 6px of headroom above the peak, 4px above the floor, so the stroke never clips.\n  const y = (v: number) => 6 + (1 - (v - min) / span) * (H - 10);\n  const xs = pts.map((_, i) => (i / (pts.length - 1)) * W);\n  const line = pts.map((v, i) => `${i ? \"L\" : \"M\"}${xs[i].toFixed(2)} ${y(v).toFixed(2)}`).join(\" \");\n  const area = `${line} L${W} ${H} L0 ${H} Z`;\n  // Where the drawn line crosses a given 0–1 position, so the scrub dot sits on it exactly.\n  const at = (p: number) => {\n    const t = p * (pts.length - 1);\n    const lo = Math.floor(t);\n    const hi = Math.min(pts.length - 1, lo + 1);\n    return y(pts[lo] + (pts[hi] - pts[lo]) * (t - lo));\n  };\n  return { line, area, at };\n}\n\nexport type StatTileProps = Omit<React.ComponentProps<\"div\">, \"children\"> &\n  NumberFormatShortcuts & {\n    label: string;\n    /** The headline value. null with loading shows the skeleton. */\n    value: number | null;\n    /** Change against the comparison period, in percentage points. */\n    delta?: number | null;\n    /** For metrics where down is good (churn, latency, cost). */\n    inverse?: boolean;\n    /** What the delta is measured against: \"vs previous 30 days\". */\n    comparison?: string;\n    /** The series behind the sparkline, oldest first. Labels are shown while scrubbing. */\n    data?: StatPoint[];\n    periods?: StatPeriod[];\n    period?: string;\n    defaultPeriod?: string;\n    onPeriodChange?: (period: string) => void;\n    /** Fetching. Skeleton when there's no value yet; otherwise the data stays and dims. */\n    loading?: boolean;\n    /** Couldn't load. Shown in place, with a retry when onRetry is given. */\n    error?: string;\n    onRetry?: () => void;\n    locale?: string;\n  };\n\nexport function StatTile({\n  label,\n  value,\n  delta,\n  inverse = false,\n  comparison,\n  data,\n  periods,\n  period: periodProp,\n  defaultPeriod,\n  onPeriodChange,\n  loading = false,\n  error,\n  onRetry,\n  currency,\n  compact,\n  decimals,\n  format,\n  locale: localeProp,\n  className,\n  ...rest\n}: StatTileProps) {\n  const locale = useLocale(localeProp);\n  const reduce = useReducedMotion();\n  const pill = useId();\n  const labelId = useId();\n  const [period, setPeriod] = useControllableState({ value: periodProp, defaultValue: defaultPeriod ?? periods?.[0]?.value ?? \"\", onChange: onPeriodChange });\n  const [scrub, setScrub] = useState<number | null>(null);\n  const [keyed, setKeyed] = useState(false);\n  const head = useRef<HTMLSpanElement>(null);\n  const prev = useRef({ value, period });\n\n  // Tint the headline when the value itself changes, never when scrubbing moves it.\n  useEffect(() => {\n    const { value: last, period: lastPeriod } = prev.current;\n    if (Object.is(last, value)) return;\n    prev.current = { value, period };\n    const el = head.current;\n    // The first value after a period switch answers a different question; it isn't a rise or a fall.\n    if (last == null || value == null || lastPeriod !== period || !el) return;\n    const good = value > last !== inverse;\n    const tint = `var(--${good ? \"success\" : \"danger\"})`;\n    el.getAnimations().forEach((a) => a.id === \"stat-tint\" && a.cancel());\n    el.animate(\n      [\n        { color: tint, offset: 0 },\n        { color: tint, offset: 0.35 },\n      ],\n      { duration: 1400, easing: \"ease-out\", id: \"stat-tint\" },\n    );\n  }, [value, inverse, period]);\n\n  const points = data ?? [];\n  const skeleton = loading && value == null;\n  const refreshing = loading && value != null;\n  const point = scrub != null ? points[scrub] : undefined;\n\n  const shortcuts = { currency, compact, decimals, format };\n  const exact = new Intl.NumberFormat(locale, numberFormatOptions({ currency, decimals, format })).format(point?.value ?? value ?? 0);\n  const pointText = (p: StatPoint) => `${p.label}: ${new Intl.NumberFormat(locale, numberFormatOptions(shortcuts)).format(p.value)}`;\n\n  // While scrubbing, the badge measures from the start of the window to the point under the finger.\n  const base = points[0]?.value;\n  const shownDelta = point ? (base ? ((point.value - base) / Math.abs(base)) * 100 : null) : delta;\n  const tone = getDelta(delta, { inverse }).tone;\n  const geo = points.length ? geometry(points.map((p) => p.value)) : null;\n  const scrubAt = scrub != null && points.length > 1 ? scrub / (points.length - 1) : null;\n\n  const move = (clientX: number, rect: DOMRect) => {\n    if (points.length < 2) return;\n    const p = Math.min(1, Math.max(0, (clientX - rect.left) / rect.width));\n    setScrub(Math.round(p * (points.length - 1)));\n    setKeyed(false);\n  };\n\n  const onKeyDown = (e: React.KeyboardEvent) => {\n    if (points.length < 2) return;\n    const lastIndex = points.length - 1;\n    const now = scrub ?? lastIndex;\n    const next =\n      e.key === \"ArrowLeft\" ? Math.max(0, scrub == null ? lastIndex : now - 1)\n      : e.key === \"ArrowRight\" ? Math.min(lastIndex, scrub == null ? lastIndex : now + 1)\n      : e.key === \"Home\" ? 0\n      : e.key === \"End\" ? lastIndex\n      : e.key === \"Escape\" ? null\n      : undefined;\n    if (next === undefined) return;\n    e.preventDefault();\n    setScrub(next);\n    setKeyed(true);\n  };\n\n  const headline = (\n    // A fixed 32px line box: the rolling digits' mask may overflow it, the layout never does.\n    <span ref={head} className=\"inline-flex h-8 items-center text-fg\">\n      <NumberTicker value={point?.value ?? value ?? 0} locale={locale} {...shortcuts} className=\"text-[28px] font-medium leading-none tracking-[-0.03em]\" />\n    </span>\n  );\n\n  return (\n    <div\n      role=\"group\"\n      aria-labelledby={labelId}\n      aria-busy={loading || undefined}\n      data-state={error ? \"error\" : skeleton ? \"loading\" : refreshing ? \"refreshing\" : \"ready\"}\n      data-scrubbing={scrub != null || undefined}\n      className={cn(\"flex w-full min-w-0 flex-col overflow-hidden rounded-xl border border-line bg-raised shadow-[var(--shadow)]\", className)}\n      {...rest}\n    >\n      <div className=\"flex h-6 box-content items-center justify-between gap-3 px-4 pt-3\">\n        <span id={labelId} className=\"flex min-w-0 items-center gap-1.5 text-[12.5px] text-fg-2\">\n          <span className=\"truncate\">{label}</span>\n          {refreshing && (\n            <>\n              <Refresh size={12} className=\"shrink-0 text-fg-4 motion-safe:animate-spin-slow\" />\n              <span className=\"sr-only\">Refreshing</span>\n            </>\n          )}\n        </span>\n        {periods && periods.length > 0 && (\n          <RadioGroup\n            value={period}\n            onValueChange={(v) => {\n              setPeriod(v as string);\n              setScrub(null);\n            }}\n            aria-label={`${label} period`}\n            className=\"-mr-1.5 flex h-6 shrink-0 rounded-md p-0.5\"\n          >\n            {periods.map((p) => {\n              const on = p.value === period;\n              return (\n                <Radio.Root\n                  key={p.value}\n                  value={p.value}\n                  nativeButton\n                  render={<button type=\"button\" />}\n                  className={cn(\n                    // 20px drawn, 44px tappable on touch.\n                    \"group/period relative rounded-[5px] px-1.5 font-mono text-[10.5px] outline-none transition-colors duration-150\",\n                    \"before:absolute before:-inset-x-0.5 before:-inset-y-3 before:content-[''] pointer-fine:before:hidden\",\n                    \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-fg-3\",\n                    on ? \"text-fg\" : \"text-fg-3 hover:text-fg-2\",\n                  )}\n                >\n                  {on && (\n                    <motion.span\n                      layoutId={pill}\n                      aria-hidden\n                      className=\"absolute inset-0 rounded-[5px] bg-hover ring-1 ring-line-2\"\n                      transition={reduce ? { duration: 0 } : spring.snappy}\n                    />\n                  )}\n                  <span className=\"relative inline-block transition-[scale] duration-100 motion-safe:group-active/period:scale-[0.94]\">{p.label}</span>\n                </Radio.Root>\n              );\n            })}\n          </RadioGroup>\n        )}\n      </div>\n\n      <div className=\"flex flex-col gap-1.5 px-4 pb-2 pt-2\">\n        {skeleton ? (\n          <>\n            <span aria-hidden className=\"flex h-8 items-center\">\n              <span className=\"h-7 w-32 rounded-md bg-fg/[0.07] motion-safe:animate-pulse-soft\" />\n            </span>\n            <span aria-hidden className=\"flex h-[18px] items-center\">\n              <span className=\"h-3 w-24 rounded-sm bg-fg/[0.07] motion-safe:animate-pulse-soft\" />\n            </span>\n            <span className=\"sr-only\">Loading {label}</span>\n          </>\n        ) : value == null && error ? (\n          <div role=\"alert\" className=\"flex h-14 items-center justify-between gap-3\">\n            <span className=\"text-[12.5px] text-fg-2\">{error}</span>\n            {onRetry && <RetryButton onClick={onRetry} />}\n          </div>\n        ) : (\n          <>\n            <div className=\"flex min-w-0 items-center gap-2\">\n              {compact ? (\n                <Tooltip.Root>\n                  <Tooltip.Trigger\n                    delay={300}\n                    render={<span tabIndex={0} role=\"img\" aria-label={exact} />}\n                    className=\"rounded-sm outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-4 focus-visible:outline-fg-3\"\n                  >\n                    {headline}\n                  </Tooltip.Trigger>\n                  <Tooltip.Portal>\n                    <Tooltip.Positioner side=\"top\" align=\"start\" sideOffset={8} collisionPadding={8} className=\"z-(--z-tooltip)\">\n                      <Tooltip.Popup className=\"origin-(--transform-origin) rounded-md border border-line-2 bg-raised px-2 py-1 font-mono text-[11.5px] text-fg shadow-pop tabular transition-[opacity,scale] duration-150 ease-out-expo data-ending-style:scale-[0.97] data-ending-style:opacity-0 data-ending-style:duration-100 data-instant:transition-none data-starting-style:scale-[0.96] data-starting-style:opacity-0\">\n                        {exact}\n                      </Tooltip.Popup>\n                    </Tooltip.Positioner>\n                  </Tooltip.Portal>\n                </Tooltip.Root>\n              ) : (\n                headline\n              )}\n              <DeltaBadge value={shownDelta} inverse={inverse} locale={locale} />\n            </div>\n            <span className=\"flex min-h-[18px] min-w-0 items-center gap-2 text-[12px] text-fg-3\">\n              {error ? (\n                <>\n                  <span className=\"truncate text-danger\">{error}</span>\n                  {onRetry && <RetryButton onClick={onRetry} small />}\n                </>\n              ) : (\n                <span className=\"truncate tabular\">{point ? `${point.label} · vs ${points[0].label}` : comparison}</span>\n              )}\n            </span>\n          </>\n        )}\n      </div>\n\n      <div\n        tabIndex={points.length > 1 && !skeleton ? 0 : -1}\n        role=\"img\"\n        aria-label={\n          points.length > 1\n            ? `${label} trend from ${points[0].label} to ${points[points.length - 1].label}. Use the arrow keys to read each point.`\n            : `No ${label.toLowerCase()} data for this period`\n        }\n        onKeyDown={onKeyDown}\n        onBlur={() => setScrub(null)}\n        onPointerMove={(e) => move(e.clientX, e.currentTarget.getBoundingClientRect())}\n        onPointerDown={(e) => {\n          e.currentTarget.setPointerCapture(e.pointerId);\n          move(e.clientX, e.currentTarget.getBoundingClientRect());\n        }}\n        onPointerLeave={() => setScrub(null)}\n        onPointerCancel={() => setScrub(null)}\n        onPointerUp={(e) => e.pointerType !== \"mouse\" && setScrub(null)}\n        className={cn(\n          \"relative h-14 touch-pan-y select-none rounded-b-[11px] outline-none transition-opacity duration-300\",\n          \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:-outline-offset-2 focus-visible:outline-fg-3\",\n          tone === \"good\" ? \"text-success\" : tone === \"bad\" ? \"text-danger\" : \"text-fg-2\",\n          refreshing && \"opacity-50\",\n          points.length > 1 && !skeleton && \"cursor-crosshair\",\n        )}\n      >\n        {skeleton ? (\n          <div aria-hidden className=\"absolute inset-x-0 bottom-0 h-8 bg-fg/[0.05] motion-safe:animate-pulse-soft [clip-path:polygon(0_70%,20%_55%,40%_62%,60%_30%,80%_40%,100%_10%,100%_100%,0_100%)]\" />\n        ) : geo ? (\n          <svg viewBox={`0 0 ${W} ${H}`} preserveAspectRatio=\"none\" aria-hidden className=\"absolute inset-0 size-full overflow-visible\">\n            <defs>\n              <linearGradient id={`${pill}-fill`} x1=\"0\" x2=\"0\" y1=\"0\" y2=\"1\">\n                <stop offset=\"0\" stopColor=\"currentColor\" stopOpacity=\"0.14\" />\n                <stop offset=\"1\" stopColor=\"currentColor\" stopOpacity=\"0\" />\n              </linearGradient>\n            </defs>\n            <motion.path\n              initial={false}\n              animate={{ d: geo.area }}\n              transition={reduce ? { duration: 0 } : { duration: 0.5, ease: ease.inOut }}\n              fill={`url(#${pill}-fill)`}\n            />\n            <motion.path\n              initial={false}\n              animate={{ d: geo.line }}\n              transition={reduce ? { duration: 0 } : { duration: 0.5, ease: ease.inOut }}\n              fill=\"none\"\n              stroke=\"currentColor\"\n              strokeWidth={1.5}\n              strokeLinejoin=\"round\"\n              strokeLinecap=\"round\"\n              vectorEffect=\"non-scaling-stroke\"\n            />\n          </svg>\n        ) : (\n          <span className=\"absolute inset-0 grid place-items-center pb-2 text-[12px] text-fg-4\">No data for this period</span>\n        )}\n\n        {geo && scrubAt != null && (\n          // Hairline and dot are HTML, not SVG, so they stay round and 1px while the chart stretches.\n          <div aria-hidden className=\"pointer-events-none absolute inset-0\">\n            <span className=\"absolute inset-y-0 w-px -translate-x-1/2 bg-fg/15\" style={{ left: `${scrubAt * 100}%` }} />\n            <span\n              className=\"absolute size-2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-current ring-2 ring-raised\"\n              style={{ left: `${scrubAt * 100}%`, top: `${(geo.at(scrubAt) / H) * 100}%` }}\n            />\n          </div>\n        )}\n      </div>\n      <span aria-live=\"polite\" className=\"sr-only\">\n        {keyed && point ? pointText(point) : \"\"}\n      </span>\n    </div>\n  );\n}\n\nfunction RetryButton({ onClick, small }: { onClick: () => void; small?: boolean }) {\n  return (\n    <button\n      type=\"button\"\n      onClick={onClick}\n      className={cn(\n        \"inline-flex shrink-0 items-center gap-1.5 rounded-md font-medium text-fg outline-none transition-[background-color,scale] duration-150 hover:bg-hover active:scale-[0.97]\",\n        \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n        small ? \"-my-1 h-6 px-1.5 text-[12px]\" : \"h-7 border border-line-2 px-2.5 text-[12px] shadow-[var(--shadow)]\",\n      )}\n    >\n      <Refresh size={12} />\n      Try again\n    </button>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/stat-tile"
}
