{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "delta-badge",
  "type": "registry:ui",
  "title": "Delta Badge",
  "description": "Signed change with one arrow that turns, tinted by whether the change is good.",
  "categories": [
    "data"
  ],
  "dependencies": [
    "@number-flow/react",
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/motion.json"
  ],
  "files": [
    {
      "path": "components/ui/delta-badge.tsx",
      "content": "\"use client\";\nimport NumberFlow, { type Format } from \"@number-flow/react\";\nimport { motion, useReducedMotion } from \"motion/react\";\nimport { useSyncExternalStore } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { ease, spring } from \"@/lib/motion\";\n\nconst noop = () => () => {};\nfunction useLocale(locale?: string) {\n  const detected = useSyncExternalStore(\n    noop,\n    () => Intl.NumberFormat().resolvedOptions().locale,\n    () => \"en-US\",\n  );\n  return locale ?? detected;\n}\n\nexport type DeltaTrend = \"up\" | \"down\" | \"flat\" | \"none\";\nexport type DeltaTone = \"good\" | \"bad\" | \"neutral\";\n\n/**\n * Where a change points and whether that's good, judged on the number as it\n * will be shown: +0.04% rounds to 0% and reads as flat, not as a green 0%.\n */\nexport function getDelta(value: number | null | undefined, { decimals = 1, inverse = false }: { decimals?: number; inverse?: boolean } = {}) {\n  if (value == null || !Number.isFinite(value)) return { trend: \"none\" as DeltaTrend, tone: \"neutral\" as DeltaTone };\n  const f = 10 ** decimals;\n  const rounded = Math.round(Math.abs(value) * f) / f;\n  if (rounded === 0) return { trend: \"flat\" as DeltaTrend, tone: \"neutral\" as DeltaTone };\n  const up = value > 0;\n  return { trend: (up ? \"up\" : \"down\") as DeltaTrend, tone: (up !== inverse ? \"good\" : \"bad\") as DeltaTone };\n}\n\nconst expo = `cubic-bezier(${ease.out.join(\",\")})`;\nconst timing = {\n  transformTiming: { duration: 550, easing: expo },\n  spinTiming: { duration: 550, easing: expo },\n  opacityTiming: { duration: 200, easing: \"ease-out\" },\n};\n\n// One arrow, turned: ↗ up, → flat, ↘ down. A flip reads as the same arrow changing its mind.\nconst rotation: Record<DeltaTrend, number> = { up: -45, flat: 0, down: 45, none: 0 };\n\nexport type DeltaBadgeProps = Omit<React.ComponentProps<\"span\">, \"children\"> & {\n  /** The change. Percentage points by default (12.4 shows +12.4%). null, NaN or Infinity shows a dash. */\n  value: number | null | undefined;\n  /** percent shows 12.4 as +12.4%; number shows the raw change, formatted with `format`. */\n  unit?: \"percent\" | \"number\";\n  /** Fraction digits shown, and the precision below which a change counts as flat. */\n  decimals?: number;\n  /** Extra Intl options for unit=\"number\", e.g. currency. */\n  format?: Format;\n  /** For latency, churn or cost: a fall is good news and turns green. */\n  inverse?: boolean;\n  /** Tinted pill, or colored text only for tables and running copy. */\n  variant?: \"soft\" | \"plain\";\n  size?: \"sm\" | \"md\";\n  locale?: string;\n  /** Shown when there's nothing to compare against (no prior period, or it was zero). */\n  emptyLabel?: string;\n};\n\nexport function DeltaBadge({\n  value,\n  unit = \"percent\",\n  decimals = 1,\n  format,\n  inverse = false,\n  variant = \"soft\",\n  size = \"md\",\n  locale: localeProp,\n  emptyLabel = \"–\",\n  className,\n  ...rest\n}: DeltaBadgeProps) {\n  const locale = useLocale(localeProp);\n  const reduce = useReducedMotion();\n  const { trend, tone } = getDelta(value, { decimals, inverse });\n  const magnitude = trend === \"none\" ? 0 : Math.abs(value as number);\n\n  // The sign is drawn by us (a true minus, not a hyphen) so Intl only formats the magnitude.\n  const options: Format = {\n    ...(unit === \"percent\" ? { style: \"percent\" } : null),\n    minimumFractionDigits: unit === \"percent\" ? decimals : 0,\n    maximumFractionDigits: decimals,\n    ...format,\n    signDisplay: \"never\",\n  };\n  const shown = unit === \"percent\" ? magnitude / 100 : magnitude;\n  const sign = trend === \"up\" ? \"+\" : trend === \"down\" ? \"−\" : \"\";\n  const text = new Intl.NumberFormat(locale, options).format(shown);\n  const spoken = trend === \"none\" ? \"No comparison\" : trend === \"flat\" ? `No change, ${text}` : `${trend === \"up\" ? \"Up\" : \"Down\"} ${text}`;\n\n  return (\n    <span\n      data-slot=\"delta-badge\"\n      data-trend={trend}\n      data-tone={tone}\n      data-variant={variant}\n      data-size={size}\n      className={cn(\n        \"inline-flex shrink-0 items-center whitespace-nowrap font-medium tabular transition-[background-color,color] duration-300 ease-out\",\n        size === \"sm\" ? \"gap-0.5 text-[11px]\" : \"gap-1 text-[12px]\",\n        variant === \"soft\" && (size === \"sm\" ? \"h-[18px] rounded-full pl-1 pr-1.5\" : \"h-[22px] rounded-full pl-1.5 pr-2\"),\n        tone === \"good\" && \"text-success\",\n        tone === \"bad\" && \"text-danger\",\n        tone === \"neutral\" && \"text-fg-3\",\n        variant === \"soft\" && tone === \"good\" && \"bg-success-soft\",\n        variant === \"soft\" && tone === \"bad\" && \"bg-danger-soft\",\n        variant === \"soft\" && tone === \"neutral\" && \"bg-fg/[0.06]\",\n        className,\n      )}\n      {...rest}\n    >\n      {trend !== \"none\" && (\n        <span aria-hidden className=\"grid size-3 shrink-0 place-items-center\">\n          <motion.svg\n            width=\"12\"\n            height=\"12\"\n            viewBox=\"0 0 12 12\"\n            fill=\"none\"\n            stroke=\"currentColor\"\n            strokeWidth={1.4}\n            strokeLinecap=\"round\"\n            strokeLinejoin=\"round\"\n            initial={false}\n            animate={{ rotate: rotation[trend] }}\n            transition={reduce ? { duration: 0 } : spring.snappy}\n          >\n            <path d=\"M2.5 6h7M6.5 3l3 3-3 3\" />\n          </motion.svg>\n        </span>\n      )}\n      <span aria-hidden className=\"inline-flex h-[1lh] items-center\">\n        {trend === \"none\" ? (\n          <span className=\"px-1\">{emptyLabel}</span>\n        ) : (\n          <NumberFlow value={shown} locales={locale} format={options} prefix={sign} animated={!reduce} {...timing} />\n        )}\n      </span>\n      <span className=\"sr-only\">{spoken}</span>\n    </span>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/delta-badge"
}
