{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "metric-card",
  "type": "registry:ui",
  "title": "Metric card",
  "description": "A compact summary for a number that needs context.",
  "categories": [
    "data"
  ],
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://irisui.dev/r/foundation.json",
    "https://irisui.dev/r/motion-tokens.json",
    "https://irisui.dev/r/animated-counter.json",
    "https://irisui.dev/r/utils.json"
  ],
  "files": [
    {
      "path": "src/registry/components/metric-card/metric-card.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useRef, useState } from \"react\";\nimport { AnimatePresence, animate, motion, useMotionValue, useReducedMotion, type Variants } from \"motion/react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport { AnimatedCounter } from \"../animated-counter/animated-counter\";\nimport { cn } from \"../lib/utils\";\nexport interface MetricCardProps {\n  label: string;\n  value: number;\n  suffix?: string;\n  context: string;\n  change?: string;\n}\n\n/** Copy that holds a number enters from the side it moved toward: a larger value rises from below, a smaller one drops from above. */\nconst rise: Variants = {\n  hidden: (direction: number) => ({\n    opacity: 0,\n    y: `${0.3 * direction}em`,\n    filter: `blur(${motionTokens.blur.soft}px)`,\n  }),\n  shown: {\n    opacity: 1,\n    y: 0,\n    filter: \"blur(0px)\",\n    transition: { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter] },\n  },\n  gone: (direction: number) => ({\n    opacity: 0,\n    y: `${-0.3 * direction}em`,\n    filter: `blur(${motionTokens.blur.subtle}px)`,\n    transition: { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.standard] },\n  }),\n};\nconst fade: Variants = {\n  hidden: { opacity: 0, y: 0, filter: \"blur(0px)\" },\n  shown: { opacity: 1, y: 0, filter: \"blur(0px)\", transition: { duration: motionTokens.duration.instant } },\n  gone: { opacity: 0, y: 0, filter: \"blur(0px)\", transition: { duration: motionTokens.duration.instant } },\n};\nconst amountIn = (text: string) => Number(text.replace(/,/g, \"\").match(/-?\\d+(?:\\.\\d+)?/)?.[0] ?? NaN);\n\n/** New copy rises in while the old copy leaves; `morph` springs the wrapper to the new text's width instead of letting it snap.\n *  The outgoing copy is popped out of flow, so the wrapper never holds both widths. */\nfunction Swap({ text, morph = false, block = false }: { text: string; morph?: boolean; block?: boolean }) {\n  const reduceMotion = !!useReducedMotion();\n  const sizer = useRef<HTMLSpanElement>(null);\n  const width = useMotionValue<number | \"auto\">(\"auto\");\n  const [shown, setShown] = useState({ text, direction: 1 });\n  if (shown.text !== text) setShown({ text, direction: amountIn(text) < amountIn(shown.text) ? -1 : 1 });\n  useEffect(() => {\n    const node = sizer.current;\n    if (!node || typeof ResizeObserver === \"undefined\") return;\n    let measured: string | null = null;\n    // Layout size, not the transformed rect, so a scaling parent never leaves the text clipped. Only a new text springs; font loads jump.\n    const observer = new ResizeObserver(([entry]) => {\n      if (!entry) return;\n      const next = entry.borderBoxSize?.[0]?.inlineSize ?? node.offsetWidth;\n      if (next && measured !== null && measured !== node.textContent && !reduceMotion)\n        animate(width, next, motionTokens.spring.morph);\n      else width.jump(next || \"auto\");\n      measured = next ? node.textContent : null;\n    });\n    observer.observe(node);\n    return () => observer.disconnect();\n  }, [morph, reduceMotion, width]);\n  return (\n    <motion.span\n      className={cn(\"relative max-w-full\", block ? \"block\" : \"inline-flex overflow-x-clip align-top whitespace-nowrap\")}\n      style={morph ? { width } : undefined}\n    >\n      {morph && (\n        <span\n          ref={sizer}\n          className=\"pointer-events-none invisible absolute top-0 left-0 whitespace-nowrap\"\n          aria-hidden=\"true\"\n        >\n          {text}\n        </span>\n      )}\n      <AnimatePresence mode=\"popLayout\" initial={false} custom={shown.direction}>\n        <motion.span\n          key={text}\n          className={block ? \"block\" : \"inline-block\"}\n          custom={shown.direction}\n          variants={reduceMotion ? fade : rise}\n          initial=\"hidden\"\n          animate=\"shown\"\n          exit=\"gone\"\n        >\n          {text}\n        </motion.span>\n      </AnimatePresence>\n    </motion.span>\n  );\n}\n\nexport function MetricCard({ label, value, suffix, context, change }: MetricCardProps) {\n  const reduceMotion = !!useReducedMotion();\n  return (\n    <article className=\"min-w-0 rounded-surface border border-line bg-surface p-6 shadow-resting max-[380px]:p-4\">\n      <div className=\"mb-8 flex items-center justify-between gap-3 max-[380px]:mb-6 max-[380px]:flex-wrap max-[380px]:items-start\">\n        <span className=\"text-sm/[inherit] text-ink-2\">\n          <Swap text={label} block />\n        </span>\n        <AnimatePresence initial={false}>\n          {change && (\n            <motion.small\n              key=\"change\"\n              className=\"inline-flex flex-none rounded-full border border-line px-2 py-[3px] text-sm/[inherit] text-ink-2 tabular-nums transition-[color,background-color] duration-fast ease-standard data-trend:border-transparent data-[trend=down]:bg-[color-mix(in_oklab,var(--red)_11%,transparent)] data-[trend=down]:text-red data-[trend=up]:bg-[color-mix(in_oklab,var(--green)_11%,transparent)] data-[trend=up]:text-green\"\n              data-trend={/^[+]/.test(change) ? \"up\" : /^[-−]/.test(change) ? \"down\" : undefined}\n              initial={{ opacity: 0, scale: reduceMotion ? 1 : 0.96 }}\n              animate={{ opacity: 1, scale: 1 }}\n              exit={{\n                opacity: 0,\n                scale: reduceMotion ? 1 : 0.96,\n                transition: { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.standard] },\n              }}\n              transition={reduceMotion ? { duration: 0 } : motionTokens.spring.snappy}\n            >\n              <Swap text={change} morph />\n            </motion.small>\n          )}\n        </AnimatePresence>\n      </div>\n      <AnimatedCounter value={value} suffix={suffix} animateOnView />\n      <p className=\"mt-4 text-sm leading-body text-ink-3\">\n        <Swap text={context} block />\n      </p>\n    </article>\n  );\n}\n",
      "type": "registry:component",
      "target": "@components/iris/metric-card/metric-card.tsx"
    }
  ],
  "docs": "Docs and live preview: https://irisui.dev/components/metric-card",
  "meta": {
    "kind": "component",
    "group": "Data display",
    "export": "MetricCard",
    "tags": [
      "data",
      "summary"
    ],
    "docs": "https://irisui.dev/components/metric-card"
  }
}
