{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "badge",
  "type": "registry:ui",
  "title": "Badge",
  "description": "A small label for status, category, or metadata.",
  "categories": [
    "data"
  ],
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://mcp.xzero.co.uk/r/xzero-motion-tokens.json"
  ],
  "files": [
    {
      "path": "registry/components/badge/badge.tsx",
      "type": "registry:component",
      "target": "@components/xzero/badge/badge.tsx",
      "content": "\"use client\";\n\nimport { isValidElement, useEffect, useLayoutEffect, useRef } from \"react\";\nimport type { HTMLAttributes, ReactNode } from \"react\";\nimport { AnimatePresence, animate, motion, useIsPresent, useMotionValue, useReducedMotion, type AnimationPlaybackControls, type HTMLMotionProps, type TargetAndTransition, type Transition } from \"motion/react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport styles from \"./badge.module.css\";\n\nexport type BadgeTone = \"neutral\" | \"success\" | \"info\" | \"warning\" | \"danger\";\nexport type BadgeSize = \"sm\" | \"md\";\n\nexport interface BadgeProps extends HTMLAttributes<HTMLSpanElement> {\n  tone?: BadgeTone;\n  size?: BadgeSize;\n  icon?: ReactNode;\n}\n\nconst exitFast: Transition = { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.standard] };\nconst textIn: TargetAndTransition = { opacity: 0, y: \"0.3em\", filter: `blur(${motionTokens.blur.soft}px)` };\nconst textOut: TargetAndTransition = { opacity: 0, y: \"-0.3em\", filter: `blur(${motionTokens.blur.subtle}px)`, transition: exitFast };\nconst iconIn: TargetAndTransition = { opacity: 0, scale: .6, filter: `blur(${motionTokens.blur.subtle}px)` };\nconst shown: TargetAndTransition = { opacity: 1, y: \"0em\", scale: 1, filter: \"blur(0px)\" };\nconst fadeOnly: TargetAndTransition = { opacity: 0, transition: { duration: motionTokens.duration.instant } };\n\n/** Outgoing copies are hidden from assistive tech while they fade, so only the current text is read. */\nfunction Swap(props: HTMLMotionProps<\"span\">) {\n  const present = useIsPresent();\n  return <motion.span {...props} aria-hidden={present ? props[\"aria-hidden\"] : true} />;\n}\n\n/** A new icon component crossfades in; re-rendering the same icon stays still. */\nfunction iconKey(icon: ReactNode) {\n  if (!isValidElement(icon)) return \"icon\";\n  const type = icon.type as string | { displayName?: string; name?: string };\n  return typeof type === \"string\" ? type : type.displayName ?? type.name ?? \"icon\";\n}\n\nexport function Badge({ tone = \"neutral\", size = \"md\", icon, className, children, ...props }: BadgeProps) {\n  const reduce = useReducedMotion();\n  const text = typeof children === \"string\" || typeof children === \"number\" ? String(children) : null;\n  const glyphKey = icon ? iconKey(icon) : \"\";\n  const body = useRef<HTMLSpanElement>(null);\n  const content = useRef<HTMLSpanElement>(null);\n  // Width stays auto at rest. Only a new label or icon springs it from the old size to the new one; passive reflows (a font swap, a hidden parent) follow instantly.\n  const width = useMotionValue<number | \"auto\">(\"auto\");\n  const changedAt = useRef(0);\n  useLayoutEffect(() => { changedAt.current = performance.now(); }, [text, glyphKey]);\n  useEffect(() => {\n    const node = content.current;\n    if (!node || typeof ResizeObserver === \"undefined\") return;\n    let last: number | undefined;\n    let controls: AnimationPlaybackControls | undefined;\n    const settle = () => { width.jump(\"auto\"); if (body.current) body.current.style.width = \"auto\"; };\n    const observer = new ResizeObserver(([entry]) => {\n      if (!entry) return;\n      const next = entry.borderBoxSize?.[0]?.inlineSize ?? node.offsetWidth;\n      const current = width.get();\n      const from = typeof current === \"number\" ? current : last;\n      last = next;\n      controls?.stop();\n      if (reduce || from === undefined || from === next || performance.now() - changedAt.current > 120) return settle();\n      // Pin the old width before this frame paints, then spring to the new one.\n      if (body.current) body.current.style.width = `${from}px`;\n      controls = animate(width, [from, next], { ...motionTokens.spring.morph, onComplete: settle });\n    });\n    observer.observe(node);\n    return () => { observer.disconnect(); controls?.stop(); };\n  }, [width, reduce]);\n  const enter: Transition = reduce ? { duration: motionTokens.duration.instant } : { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter] };\n  const classes = [styles.badge, styles[tone], styles[size], className].filter(Boolean).join(\" \");\n  // The pill follows its content: text and icon swap in place while the width springs to the new size.\n  return <span {...props} className={classes}>\n    <motion.span ref={body} className={styles.body} style={{ width }}>\n      <span ref={content} className={styles.content}>\n        {icon ? <span className={styles.icon} aria-hidden=\"true\"><AnimatePresence mode=\"popLayout\" initial={false}><Swap key={glyphKey} className={styles.glyph} initial={reduce ? { opacity: 0 } : iconIn} animate={shown} exit={reduce ? fadeOnly : { ...iconIn, transition: exitFast }} transition={reduce ? enter : motionTokens.spring.snappy}>{icon}</Swap></AnimatePresence></span> : null}\n        {text === null ? children : <span className={styles.label}><AnimatePresence mode=\"popLayout\" initial={false}><Swap key={text} className={styles.text} initial={reduce ? { opacity: 0 } : textIn} animate={shown} exit={reduce ? fadeOnly : textOut} transition={enter}>{text}</Swap></AnimatePresence></span>}\n      </span>\n    </motion.span>\n  </span>;\n}\n\nexport default Badge;\n"
    },
    {
      "path": "registry/components/badge/badge.module.css",
      "type": "registry:component",
      "target": "@components/xzero/badge/badge.module.css",
      "content": ".badge {\n  display: inline-flex;\n  min-height: 26px;\n  align-items: center;\n  border: 1px solid var(--badge-border, var(--border));\n  border-radius: var(--radius-pill);\n  padding: 0 10px;\n  color: var(--badge-foreground, var(--foreground));\n  background: var(--badge-background, var(--surface));\n  font-size: var(--text-xs);\n  font-weight: 500;\n  letter-spacing: -.01em;\n  line-height: 1;\n  white-space: nowrap;\n  /* Clip keeps the inline baseline while the pill morphs around swapped text. */\n  overflow: clip;\n  transition: border-color var(--duration-standard) var(--ease-standard), background-color var(--duration-standard) var(--ease-standard), color var(--duration-standard) var(--ease-standard);\n}\n\n@media (hover: hover) and (pointer: fine) {\n  .badge:hover { border-color: color-mix(in oklab, var(--badge-color, var(--foreground)) 32%, var(--border)); background: color-mix(in oklab, var(--badge-color, var(--foreground)) 10%, var(--surface)); }\n}\n.sm { min-height: 22px; padding: 0 8px; font-size: 11px; }\n.md { font-size: var(--text-xs); }\n\n.body { display: block; }\n.content { position: relative; display: inline-flex; width: max-content; align-items: center; gap: 5px; }\n.sm .content { gap: 4px; }\n.label, .glyph { position: relative; display: inline-flex; align-items: center; }\n.text { display: block; }\n\n.icon {\n  position: relative;\n  display: inline-flex;\n  width: auto;\n  height: auto;\n  flex: 0 0 auto;\n  align-items: center;\n  justify-content: center;\n  color: var(--badge-color, var(--foreground));\n  line-height: 1;\n}\n\n.neutral { --badge-color: var(--text-secondary); --badge-foreground: var(--text-secondary); --badge-background: var(--surface-muted); --badge-border: var(--border); }\n.success { --badge-color: var(--success); --badge-foreground: var(--success); --badge-background: color-mix(in oklab, var(--success) 10%, var(--surface)); --badge-border: color-mix(in oklab, var(--success) 25%, var(--border)); }\n.info { --badge-color: var(--accent); --badge-foreground: var(--accent-strong); --badge-background: var(--accent-subtle); --badge-border: color-mix(in oklab, var(--accent) 24%, var(--border)); }\n.warning { --badge-color: var(--warning); --badge-foreground: var(--warning); --badge-background: color-mix(in oklab, var(--warning) 11%, var(--surface)); --badge-border: color-mix(in oklab, var(--warning) 27%, var(--border)); }\n.danger { --badge-color: var(--danger); --badge-foreground: var(--danger); --badge-background: color-mix(in oklab, var(--danger) 10%, var(--surface)); --badge-border: color-mix(in oklab, var(--danger) 26%, var(--border)); }\n\n@media (prefers-reduced-motion: reduce) {\n  .badge { transition-duration: var(--duration-instant); }\n}\n"
    }
  ],
  "docs": "Docs and live preview: https://ui.xzero.co.uk/components/badge",
  "meta": {
    "tier": "free",
    "kind": "component",
    "tags": [
      "label",
      "status"
    ],
    "docs": "https://ui.xzero.co.uk/components/badge",
    "markdown": "https://ui.xzero.co.uk/components/badge/markdown"
  }
}