{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "stat",
  "title": "Stat",
  "description": "One number with its label, change and a small trend line. Colour only appears once you have said which direction is good.",
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/miseui/ui/stat.tsx",
      "content": "import type * as React from \"react\"\nimport { useId } from \"react\"\nimport { useReducedMotion } from \"motion/react\"\nimport { CountUp, StatChange, StatSparkline } from \"@/registry/miseui/ui/stat-parts\"\nimport type { StatPolarity } from \"@/registry/miseui/ui/stat-math\"\nimport { cn } from \"@/registry/miseui/lib/utils\"\n\ntype StatVariant = \"plain\" | \"card\" | \"compact\" | \"inline\"\ntype StatEffect = \"count-up\"\n\ntype StatProps = Omit<React.ComponentProps<\"div\">, \"children\"> & {\n  label: React.ReactNode\n  /** A number can be formatted and counted; a string such as \"4m 12s\" is shown as it is. */\n  value: number | string\n  /** `plain` a large figure, `card` a surface with the trend along its foot, `compact` one table-like row, `inline` a run of text. */\n  variant?: StatVariant\n  /** `count-up` counts once when first seen. People who turned motion off get the final figure. */\n  effect?: StatEffect\n  /** Formats a numeric value. Defaults to grouped digits in the given locale. */\n  format?: (value: number) => string\n  locale?: string\n  /** The change since the last period, signed. */\n  change?: number\n  /** Written after the change. Defaults to a percent sign. */\n  changeSuffix?: string\n  /** A few words of context after the change, such as \"vs last month\". */\n  changeNote?: React.ReactNode\n  /** Which way is good. `neutral` keeps the change grey. */\n  polarity?: StatPolarity\n  /** Samples for a small line. Described to assistive tech through `trendLabel`. */\n  trend?: readonly number[]\n  trendLabel?: string\n}\n\n/** cqi keeps a long figure on one line in a narrow cell; the clamp stops it shouting in a wide one. */\nconst VALUE_CLASS: Record<StatVariant, string> = {\n  plain: \"text-[clamp(24px,14cqi,32px)] leading-tight font-semibold tracking-tight\",\n  card: \"text-[clamp(24px,12cqi,32px)] leading-tight font-semibold tracking-tight\",\n  compact: \"text-[15px] leading-6 font-medium\",\n  inline: \"text-[15px] leading-6 font-semibold\",\n}\n\nconst ROOT_CLASS: Record<StatVariant, string> = {\n  // w-full: a size container takes no width from its content, so as a flex item it would otherwise collapse to nothing.\n  plain: \"@container grid w-full min-w-0 gap-1\",\n  card: \"@container grid w-full min-w-0 overflow-hidden rounded-lg border bg-card text-card-foreground shadow-xs\",\n  compact: \"grid min-w-0 grid-cols-[minmax(0,1fr)_auto] items-center gap-x-4 border-b py-2 last:border-b-0\",\n  inline: \"inline-flex max-w-full flex-wrap items-baseline gap-x-2\",\n}\n\nconst defaultFormat = (locale?: string) => (value: number) => new Intl.NumberFormat(locale, { maximumFractionDigits: 20 }).format(value)\n\ninterface StatFigureProps {\n  value: number | string\n  format: (value: number) => string\n  counting: boolean\n  reduceMotion: boolean\n  className: string\n}\n\nfunction StatFigure({ value, format, counting, reduceMotion, className }: StatFigureProps) {\n  const numeric = typeof value === \"number\"\n  return (\n    // Tabular figures: a digit changing from 1 to 8 must not push everything beside it.\n    <span data-slot=\"stat-value\" className={cn(\"block min-w-0 tabular-nums wrap-anywhere\", className)}>\n      {numeric && counting ? <CountUp value={value} format={format} reduceMotion={reduceMotion} /> : numeric ? format(value) : value}\n    </span>\n  )\n}\n\nfunction StatTrendText({ trend, trendLabel }: Pick<StatProps, \"trend\" | \"trendLabel\">) {\n  if (!trend || trend.length === 0 || !trendLabel) return null\n  return <span className=\"sr-only\">{trendLabel}</span>\n}\n\ninterface StatLayoutProps {\n  variant: StatVariant\n  name: React.ReactNode\n  figure: React.ReactNode\n  changed: React.ReactNode\n  line: (className: string, filled?: boolean) => React.ReactNode\n}\n\nfunction StatLayout({ variant, name, figure, changed, line }: StatLayoutProps) {\n  if (variant === \"card\") {\n    return (\n      <>\n        <div className=\"grid min-w-0 gap-2 p-4\">\n          {name}\n          {figure}\n          {changed}\n        </div>\n        {line(\"h-12 w-full\", true)}\n      </>\n    )\n  }\n  if (variant === \"compact\") {\n    return (\n      <>\n        <div className=\"grid min-w-0\">\n          {name}\n          {changed}\n        </div>\n        <div className=\"flex min-w-0 items-center gap-2\">\n          {line(\"h-6 w-12 max-[320px]:hidden\")}\n          {figure}\n        </div>\n      </>\n    )\n  }\n  return (\n    <>\n      {name}\n      {figure}\n      {changed}\n      {line(variant === \"inline\" ? \"h-4 w-12 self-center\" : \"mt-2 h-8 w-full\")}\n    </>\n  )\n}\n\nfunction Stat({\n  label,\n  value,\n  variant = \"plain\",\n  effect,\n  format,\n  locale,\n  change,\n  changeSuffix = \"%\",\n  changeNote,\n  polarity = \"neutral\",\n  trend,\n  trendLabel,\n  className,\n  ...props\n}: StatProps) {\n  const labelId = useId()\n  const reduceMotion = useReducedMotion() ?? false\n  const line = (lineClass: string, filled = false) =>\n    trend && trend.length > 0 ? <StatSparkline values={trend} polarity={polarity} change={change} filled={filled} className={lineClass} /> : null\n  return (\n    <div role=\"group\" aria-labelledby={labelId} data-slot=\"stat\" data-variant={variant} data-effect={effect} className={cn(ROOT_CLASS[variant], className)} {...props}>\n      <StatLayout\n        variant={variant}\n        line={line}\n        name={\n          <span id={labelId} data-slot=\"stat-label\" className=\"min-w-0 text-[13px] leading-5 text-muted-foreground\">\n            {label}\n          </span>\n        }\n        figure={\n          <StatFigure value={value} format={format ?? defaultFormat(locale)} counting={effect === \"count-up\"} reduceMotion={reduceMotion} className={VALUE_CLASS[variant]} />\n        }\n        changed={change === undefined ? null : <StatChange change={change} suffix={changeSuffix} polarity={polarity} locale={locale} note={changeNote} />}\n      />\n      <StatTrendText trend={trend} trendLabel={trendLabel} />\n    </div>\n  )\n}\n\nexport { Stat, type StatProps, type StatVariant, type StatEffect, type StatPolarity }\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/miseui/ui/stat-math.ts",
      "content": "type StatPolarity = \"neutral\" | \"up-is-good\" | \"down-is-good\"\ntype StatDirection = \"up\" | \"down\" | \"flat\"\ntype StatTone = \"good\" | \"bad\" | \"neutral\"\n\nfunction changeDirection(change: number): StatDirection {\n  if (!Number.isFinite(change) || change === 0) return \"flat\"\n  return change > 0 ? \"up\" : \"down\"\n}\n\n/** Colour only speaks when someone has said which way is good. Support tickets going up is not a win. */\nfunction changeTone(direction: StatDirection, polarity: StatPolarity): StatTone {\n  if (polarity === \"neutral\" || direction === \"flat\") return \"neutral\"\n  const good = polarity === \"up-is-good\" ? \"up\" : \"down\"\n  return direction === good ? \"good\" : \"bad\"\n}\n\nfunction formatChange(change: number, suffix: string, locale?: string) {\n  const size = new Intl.NumberFormat(locale, { maximumFractionDigits: 2 }).format(Math.abs(change))\n  const direction = changeDirection(change)\n  // A real minus sign, as wide as the plus, so a column of changes lines up.\n  const sign = direction === \"up\" ? \"+\" : direction === \"down\" ? \"−\" : \"\"\n  return `${sign}${size}${suffix}`\n}\n\n/** How many decimals the final number has, so a count-up to 3.4 never shows 3.41827 on the way. */\nfunction decimalsOf(value: number) {\n  if (!Number.isFinite(value) || Number.isInteger(value)) return 0\n  const text = String(value)\n  if (text.includes(\"e\")) return 0\n  return text.length - text.indexOf(\".\") - 1\n}\n\nfunction roundTo(value: number, decimals: number) {\n  const factor = 10 ** decimals\n  return Math.round(value * factor) / factor\n}\n\n/**\n * Points for a line through the values. Flat data sits in the middle instead of on the floor, where it would read\n * as zero. Non-finite samples are dropped: one bad reading must not erase the whole line.\n */\nfunction sparklinePoints(values: readonly number[], width: number, height: number, inset = 2) {\n  const finite = values.filter((value) => Number.isFinite(value))\n  if (finite.length === 0) return []\n  const min = Math.min(...finite)\n  const max = Math.max(...finite)\n  const range = max - min\n  const usable = height - inset * 2\n  const step = finite.length > 1 ? width / (finite.length - 1) : 0\n  return finite.map((value, index) => ({\n    x: finite.length > 1 ? index * step : width / 2,\n    y: range === 0 ? height / 2 : inset + usable - ((value - min) / range) * usable,\n  }))\n}\n\nfunction sparklinePath(values: readonly number[], width: number, height: number) {\n  const points = sparklinePoints(values, width, height)\n  if (points.length === 0) return \"\"\n  // One sample has no slope, so it is drawn as a level line across the box instead of an invisible dot.\n  if (points.length === 1) return `M0 ${points[0].y} L${width} ${points[0].y}`\n  return points.map((point, index) => `${index === 0 ? \"M\" : \"L\"}${point.x.toFixed(2)} ${point.y.toFixed(2)}`).join(\" \")\n}\n\nfunction sparklineArea(values: readonly number[], width: number, height: number) {\n  const line = sparklinePath(values, width, height)\n  return line ? `${line} L${width} ${height} L0 ${height} Z` : \"\"\n}\n\nexport {\n  changeDirection,\n  changeTone,\n  decimalsOf,\n  formatChange,\n  roundTo,\n  sparklineArea,\n  sparklinePath,\n  sparklinePoints,\n  type StatDirection,\n  type StatPolarity,\n  type StatTone,\n}\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/miseui/ui/stat-parts.tsx",
      "content": "import type * as React from \"react\"\nimport { useEffect, useRef } from \"react\"\nimport { animate, motion, useMotionValue, useTransform } from \"motion/react\"\nimport { ArrowDownRight, ArrowUpRight, Minus } from \"lucide-react\"\nimport {\n  changeDirection,\n  changeTone,\n  decimalsOf,\n  formatChange,\n  roundTo,\n  sparklineArea,\n  sparklinePath,\n  type StatPolarity,\n  type StatTone,\n} from \"@/registry/miseui/ui/stat-math\"\nimport { cn } from \"@/registry/miseui/lib/utils\"\n\n/** Longer than a press spring: a count has to be seen moving, then settle without overshooting the real number. */\nconst COUNT_SPRING = { type: \"spring\", bounce: 0, duration: 1 } as const\nconst SPARK_WIDTH = 100\nconst SPARK_HEIGHT = 32\n\n/** Fixed hues, never the accent: with a red or amber accent the theme colour would say the opposite. */\nconst TONE_TEXT_CLASS: Record<StatTone, string> = {\n  good: \"text-emerald-700 dark:text-emerald-400\",\n  bad: \"text-destructive\",\n  neutral: \"text-muted-foreground\",\n}\n\nconst DIRECTION_WORD = { up: \"Up\", down: \"Down\", flat: \"No change\" } as const\n\ninterface StatChangeProps {\n  change: number\n  suffix: string\n  polarity: StatPolarity\n  locale?: string\n  note?: React.ReactNode\n  className?: string\n}\n\nfunction StatChange({ change, suffix, polarity, locale, note, className }: StatChangeProps) {\n  const direction = changeDirection(change)\n  const tone = changeTone(direction, polarity)\n  const Icon = direction === \"up\" ? ArrowUpRight : direction === \"down\" ? ArrowDownRight : Minus\n  const text = formatChange(change, suffix, locale)\n  return (\n    <span data-slot=\"stat-change\" data-direction={direction} data-tone={tone} className={cn(\"inline-flex min-w-0 flex-wrap items-center gap-x-1 text-[13px] leading-5\", className)}>\n      {/* The arrow and sign carry the direction, so red and green are never the only signal. */}\n      <span className={cn(\"inline-flex items-center gap-1 font-medium tabular-nums\", TONE_TEXT_CLASS[tone])}>\n        <Icon aria-hidden=\"true\" className=\"size-4 shrink-0\" />\n        <span aria-hidden=\"true\">{text}</span>\n        <span className=\"sr-only\">{direction === \"flat\" ? DIRECTION_WORD.flat : `${DIRECTION_WORD[direction]} ${text.slice(1)}`}</span>\n      </span>\n      {note ? <span className=\"text-muted-foreground\">{note}</span> : null}\n    </span>\n  )\n}\n\ninterface StatSparklineProps {\n  values: readonly number[]\n  polarity: StatPolarity\n  change?: number\n  filled?: boolean\n  className?: string\n}\n\nfunction StatSparkline({ values, polarity, change, filled = false, className }: StatSparklineProps) {\n  const tone = changeTone(changeDirection(change ?? 0), polarity)\n  const line = sparklinePath(values, SPARK_WIDTH, SPARK_HEIGHT)\n  if (!line) return null\n  return (\n    <svg\n      aria-hidden=\"true\"\n      data-slot=\"stat-sparkline\"\n      viewBox={`0 0 ${SPARK_WIDTH} ${SPARK_HEIGHT}`}\n      // Stretches to any width; non-scaling-stroke below keeps the line one weight whatever the stretch.\n      preserveAspectRatio=\"none\"\n      fill=\"none\"\n      className={cn(\"block\", TONE_TEXT_CLASS[tone], className)}\n    >\n      {filled ? <path d={sparklineArea(values, SPARK_WIDTH, SPARK_HEIGHT)} fill=\"currentColor\" opacity=\"0.1\" /> : null}\n      <path d={line} stroke=\"currentColor\" strokeWidth=\"1.5\" strokeLinecap=\"round\" strokeLinejoin=\"round\" vectorEffect=\"non-scaling-stroke\" />\n    </svg>\n  )\n}\n\ninterface CountUpProps {\n  value: number\n  format: (value: number) => string\n  reduceMotion: boolean\n}\n\nfunction CountUp({ value, format, reduceMotion }: CountUpProps) {\n  const holder = useRef<HTMLSpanElement>(null)\n  const played = useRef(false)\n  const count = useMotionValue(reduceMotion ? value : 0)\n  const decimals = decimalsOf(value)\n  const text = useTransform(count, (current) => format(roundTo(current, decimals)))\n  useEffect(() => {\n    const node = holder.current\n    // A later change of value is news, not an introduction: it lands at once.\n    if (reduceMotion || played.current || !node || typeof IntersectionObserver === \"undefined\") {\n      played.current = true\n      count.set(value)\n      return\n    }\n    let controls: ReturnType<typeof animate> | undefined\n    const observer = new IntersectionObserver(([entry]) => {\n      if (!entry?.isIntersecting) return\n      observer.disconnect()\n      played.current = true\n      controls = animate(count, value, COUNT_SPRING)\n    })\n    observer.observe(node)\n    return () => {\n      observer.disconnect()\n      controls?.stop()\n    }\n  }, [count, value, reduceMotion])\n  const final = format(value)\n  return (\n    <span ref={holder} data-slot=\"stat-count\" className=\"relative inline-block max-w-full\">\n      {/* The final text holds the width, so the digits count inside a box that never resizes. */}\n      <span className=\"invisible\" aria-hidden=\"true\">\n        {final}\n      </span>\n      <motion.span aria-hidden=\"true\" data-slot=\"stat-count-value\" className=\"absolute inset-0\">\n        {text}\n      </motion.span>\n      <span className=\"sr-only\">{final}</span>\n    </span>\n  )\n}\n\nexport { CountUp, StatChange, StatSparkline }\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "display"
  ],
  "type": "registry:ui"
}