{
  "$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://mcp.xzero.co.uk/r/xzero-motion-tokens.json",
    "https://mcp.xzero.co.uk/r/animated-counter.json"
  ],
  "files": [
    {
      "path": "registry/components/metric-card/metric-card.tsx",
      "type": "registry:component",
      "target": "@components/xzero/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 styles from \"./metric-card.module.css\";\nexport interface MetricCardProps { label: string; value: number; suffix?: string; context: string; change?: string }\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 = { hidden: (direction: number) => ({ opacity: 0, y: `${.3 * direction}em`, filter: `blur(${motionTokens.blur.soft}px)` }), shown: { opacity: 1, y: 0, filter: \"blur(0px)\", transition: { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter] } }, gone: (direction: number) => ({ opacity: 0, y: `${-.3 * direction}em`, filter: `blur(${motionTokens.blur.subtle}px)`, transition: { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.standard] } }) };\nconst fade: Variants = { hidden: { opacity: 0, y: 0, filter: \"blur(0px)\" }, shown: { opacity: 1, y: 0, filter: \"blur(0px)\", transition: { duration: motionTokens.duration.instant } }, gone: { opacity: 0, y: 0, filter: \"blur(0px)\", transition: { duration: motionTokens.duration.instant } } };\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. */\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) 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 <motion.span className={block ? styles.swapBlock : styles.swap} style={morph ? { width } : undefined}>\n    {morph && <span ref={sizer} className={styles.sizer} aria-hidden=\"true\">{text}</span>}\n    <AnimatePresence mode=\"popLayout\" initial={false} custom={shown.direction}><motion.span key={text} className={styles.text} custom={shown.direction} variants={reduceMotion ? fade : rise} initial=\"hidden\" animate=\"shown\" exit=\"gone\">{text}</motion.span></AnimatePresence>\n  </motion.span>;\n}\n\nexport function MetricCard({ label, value, suffix, context, change }: MetricCardProps) {\n  const reduceMotion = !!useReducedMotion();\n  return <article className={styles.card}>\n    <div className={styles.top}><span><Swap text={label} block /></span><AnimatePresence initial={false}>{change && <motion.small key=\"change\" data-trend={/^[+]/.test(change) ? \"up\" : /^[-−]/.test(change) ? \"down\" : undefined} initial={{ opacity: 0, scale: reduceMotion ? 1 : .96 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: reduceMotion ? 1 : .96, transition: { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.standard] } }} transition={reduceMotion ? { duration: 0 } : motionTokens.spring.snappy}><Swap text={change} morph /></motion.small>}</AnimatePresence></div>\n    <AnimatedCounter value={value} suffix={suffix} animateOnView />\n    <p><Swap text={context} block /></p>\n  </article>;\n}\n"
    },
    {
      "path": "registry/components/metric-card/metric-card.module.css",
      "type": "registry:component",
      "target": "@components/xzero/metric-card/metric-card.module.css",
      "content": ".card { min-width: 0; border: 1px solid var(--border); border-radius: var(--radius-surface); padding: var(--space-6); background: var(--surface); box-shadow: var(--shadow-resting); }\n.top { display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); margin-bottom: var(--space-8); }\n.top span { color: var(--text-secondary); font-size: var(--text-sm); }\n.top small { display: inline-flex; flex: 0 0 auto; border: 1px solid var(--border); border-radius: var(--radius-pill); padding: 3px 8px; color: var(--text-secondary); font-size: var(--text-sm); font-variant-numeric: tabular-nums; }\n.card p { margin: var(--space-4) 0 0; color: var(--text-muted); font-size: var(--text-sm); line-height: var(--leading-body); }\n@media (max-width: 380px) { .card { padding: var(--space-4); } .top { align-items: flex-start; flex-wrap: wrap; margin-bottom: var(--space-6); } }\n/* Text swaps: the outgoing copy is popped out of flow so the wrapper never holds both widths. */\n.swap, .swapBlock { position: relative; max-width: 100%; }\n.swap { display: inline-flex; overflow-x: clip; white-space: nowrap; vertical-align: top; }\n.swapBlock { display: block; }\n.swap > .text { display: inline-block; }\n.swapBlock > .text { display: block; }\n.sizer { position: absolute; top: 0; left: 0; visibility: hidden; white-space: nowrap; pointer-events: none; }\n/* A signed change reads its direction in color as well as sign: a faint tint behind, the color on the text. */\n.top small[data-trend] span { color: inherit; }\n.top small[data-trend] { border-color: transparent; transition: color var(--duration-fast) var(--ease-standard), background-color var(--duration-fast) var(--ease-standard); }\n.top small[data-trend=\"up\"] { background: color-mix(in oklab, var(--success) 11%, transparent); color: var(--success); }\n.top small[data-trend=\"down\"] { background: color-mix(in oklab, var(--danger) 11%, transparent); color: var(--danger); }\n"
    }
  ],
  "docs": "Docs and live preview: https://ui.xzero.co.uk/components/metric-card",
  "meta": {
    "tier": "free",
    "kind": "component",
    "tags": [
      "data",
      "summary"
    ],
    "docs": "https://ui.xzero.co.uk/components/metric-card",
    "markdown": "https://ui.xzero.co.uk/components/metric-card/markdown"
  }
}