{
  "$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://irisui.dev/r/foundation.json",
    "https://irisui.dev/r/utils.json",
    "https://irisui.dev/r/motion-tokens.json"
  ],
  "files": [
    {
      "path": "src/registry/components/badge/badge.tsx",
      "content": "\"use client\";\n\nimport { isValidElement, useEffect, useLayoutEffect, useRef } from \"react\";\nimport type { HTMLAttributes, ReactNode } from \"react\";\nimport {\n  AnimatePresence,\n  animate,\n  motion,\n  useIsPresent,\n  useMotionValue,\n  useReducedMotion,\n  type AnimationPlaybackControls,\n  type HTMLMotionProps,\n  type TargetAndTransition,\n  type Transition,\n} from \"motion/react\";\nimport { cn } from \"../lib/utils\";\nimport { motionTokens } from \"../lib/motion-tokens\";\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\n/** Each tone tints its border and fill from one colour, which the hover state and the icon also read as `--badge-color`. */\nconst tones: Record<BadgeTone, string> = {\n  neutral: \"border-line bg-hover text-ink-2 [--badge-color:var(--ink-2)]\",\n  success:\n    \"border-[color-mix(in_oklab,var(--green)_25%,var(--line))] bg-[color-mix(in_oklab,var(--green)_10%,var(--surface))] text-green [--badge-color:var(--green)]\",\n  info: \"border-[color-mix(in_oklab,var(--accent)_24%,var(--line))] bg-accent-tint text-accent-ink [--badge-color:var(--accent)]\",\n  warning:\n    \"border-[color-mix(in_oklab,var(--orange)_27%,var(--line))] bg-[color-mix(in_oklab,var(--orange)_11%,var(--surface))] text-orange [--badge-color:var(--orange)]\",\n  danger:\n    \"border-[color-mix(in_oklab,var(--red)_26%,var(--line))] bg-[color-mix(in_oklab,var(--red)_10%,var(--surface))] text-red [--badge-color:var(--red)]\",\n};\nconst sizes: Record<BadgeSize, string> = {\n  sm: \"min-h-[22px] px-2 text-[11px]/none\",\n  md: \"text-xs/none\",\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 = {\n  opacity: 0,\n  y: \"-0.3em\",\n  filter: `blur(${motionTokens.blur.subtle}px)`,\n  transition: exitFast,\n};\nconst iconIn: TargetAndTransition = { opacity: 0, scale: 0.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(() => {\n    changedAt.current = performance.now();\n  }, [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 = () => {\n      width.jump(\"auto\");\n      if (body.current) body.current.style.width = \"auto\";\n    };\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 () => {\n      observer.disconnect();\n      controls?.stop();\n    };\n  }, [width, reduce]);\n  const enter: Transition = reduce\n    ? { duration: motionTokens.duration.instant }\n    : { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter] };\n  // Clip keeps the inline baseline while the pill morphs around swapped text.\n  const classes = cn(\n    \"inline-flex min-h-[26px] items-center overflow-clip rounded-full border px-2.5 text-xs/none font-medium tracking-[-.01em] whitespace-nowrap transition-[border-color,background-color,color] duration-standard ease-standard motion-reduce:duration-instant\",\n    \"hover:border-[color-mix(in_oklab,var(--badge-color)_32%,var(--line))] hover:bg-[color-mix(in_oklab,var(--badge-color)_10%,var(--surface))]\",\n    tones[tone],\n    sizes[size],\n    className,\n  );\n  // The pill follows its content: text and icon swap in place while the width springs to the new size.\n  return (\n    <span {...props} className={classes}>\n      <motion.span ref={body} className=\"block\" style={{ width }}>\n        <span\n          ref={content}\n          className={cn(\"relative inline-flex w-max items-center\", size === \"sm\" ? \"gap-1\" : \"gap-[5px]\")}\n        >\n          {icon ? (\n            <span\n              className=\"relative inline-flex flex-none items-center justify-center leading-none text-(--badge-color)\"\n              aria-hidden=\"true\"\n            >\n              <AnimatePresence mode=\"popLayout\" initial={false}>\n                <Swap\n                  key={glyphKey}\n                  className=\"relative inline-flex items-center\"\n                  initial={reduce ? { opacity: 0 } : iconIn}\n                  animate={shown}\n                  exit={reduce ? fadeOnly : { ...iconIn, transition: exitFast }}\n                  transition={reduce ? enter : motionTokens.spring.snappy}\n                >\n                  {icon}\n                </Swap>\n              </AnimatePresence>\n            </span>\n          ) : null}\n          {text === null ? (\n            children\n          ) : (\n            <span className=\"relative inline-flex items-center\">\n              <AnimatePresence mode=\"popLayout\" initial={false}>\n                <Swap\n                  key={text}\n                  className=\"block\"\n                  initial={reduce ? { opacity: 0 } : textIn}\n                  animate={shown}\n                  exit={reduce ? fadeOnly : textOut}\n                  transition={enter}\n                >\n                  {text}\n                </Swap>\n              </AnimatePresence>\n            </span>\n          )}\n        </span>\n      </motion.span>\n    </span>\n  );\n}\n\nexport default Badge;\n",
      "type": "registry:component",
      "target": "@components/iris/badge/badge.tsx"
    }
  ],
  "docs": "Docs and live preview: https://irisui.dev/components/badge",
  "meta": {
    "kind": "component",
    "group": "Data display",
    "export": "Badge",
    "tags": [
      "label",
      "status"
    ],
    "docs": "https://irisui.dev/components/badge"
  }
}
