{"$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://uiarc.dev/r/arc-motion-tokens.json"],"files":[{"path":"registry/components/badge/badge.tsx","type":"registry:component","target":"@components/arc/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\" | \"lg\";\nexport type BadgeColor = \"teal\" | \"sky\" | \"orchid\" | \"violet\" | \"rose\" | \"amber\";\n\nexport interface BadgeProps extends HTMLAttributes<HTMLSpanElement> {\n  tone?: BadgeTone;\n  size?: BadgeSize;\n  icon?: ReactNode;\n  /** A small status dot in the tone colour. Ignored when an icon is set. */\n  dot?: boolean;\n  /** Categorical colour from the shared tone family, for labels such as method tags. Overrides tone. */\n  color?: BadgeColor;\n  /** Draws a hairline border in the tone colour. */\n  outline?: boolean;\n  /** Fully rounded ends instead of the default soft corners. */\n  pill?: boolean;\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, dot = false, color, outline = false, pill = false, 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 mark = icon ? styles.withIcon : dot ? styles.withDot : undefined;\n  const classes = [styles.badge, styles[tone], color ? styles[color] : undefined, styles[size], mark, outline ? styles.outline : undefined, pill ? styles.pill : undefined, 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 && dot ? <span className={styles.icon} aria-hidden=\"true\"><span className={styles.dot} /></span> : null}\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/arc/badge/badge.module.css","content":"/*\n * One formula tints every tone and colour from the shared --tone-* family, so they read as one set in both themes:\n *   background  the tone mixed into the surface (10% light, a barely tinted 12% dark)\n *   text        the tone, deepened toward the foreground in light (AA on the tint) and the bright pastel itself in dark\n *   mark        the icon or dot, closer to the pure tone\n *   ring        transparent by default; `outline` draws a hairline of the tone\n * All mixes run in oklab: oklch tints turn pink after the production build.\n */\n.badge {\n  --badge-tone: var(--text-secondary);\n  --badge-tint: 10%;\n  --badge-ink: 84%;\n  --badge-background: color-mix(in oklab, var(--badge-tone) var(--badge-tint), var(--surface));\n  --badge-foreground: color-mix(in oklab, var(--badge-tone) var(--badge-ink), var(--foreground));\n  --badge-color: color-mix(in oklab, var(--badge-tone) 88%, var(--foreground));\n  --badge-border: transparent;\n  --badge-radius: 6px;\n  --badge-height: 24px;\n  --badge-pad: 10px;\n  --badge-gap: 5px;\n  --badge-icon: 14px;\n  --badge-dot: 6px;\n  box-sizing: border-box;\n  display: inline-flex;\n  min-height: var(--badge-height);\n  flex: none;\n  align-items: center;\n  justify-content: center;\n  vertical-align: middle;\n  border: 1px solid var(--badge-border);\n  border-radius: var(--badge-radius);\n  padding: 0 var(--badge-pad);\n  color: var(--badge-foreground);\n  background: var(--badge-background);\n  font-family: var(--font-body);\n  font-size: var(--text-xs);\n  font-weight: 500;\n  font-variant-numeric: tabular-nums;\n  letter-spacing: -.005em;\n  line-height: 1;\n  white-space: nowrap;\n  /* Clip keeps the corners clean while the badge 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:global(:root[data-theme=\"dark\"]) .badge { --badge-tint: 12%; --badge-ink: 94%; }\n.outline { --badge-border: color-mix(in oklab, var(--badge-tone) 14%, var(--border)); }\n:global(:root[data-theme=\"dark\"]) .outline { --badge-border: color-mix(in oklab, var(--badge-tone) 22%, var(--border)); }\n\n/* Sizes on the four point grid. The leading edge tightens beside a mark so the visual padding matches the trailing edge. */\n.sm { --badge-height: 20px; --badge-radius: 5px; --badge-pad: 8px; --badge-gap: 4px; --badge-icon: 12px; --badge-dot: 5px; font-size: 11px; }\n.md { --badge-height: 24px; --badge-radius: 6px; --badge-pad: 10px; --badge-gap: 5px; --badge-icon: 14px; --badge-dot: 6px; }\n.lg { --badge-height: 28px; --badge-radius: 7px; --badge-pad: 12px; --badge-gap: 6px; --badge-icon: 16px; --badge-dot: 7px; font-size: var(--text-sm); }\n/* Full round ends, the original look. Declared after the sizes so it wins over their radius. */\n.pill { --badge-radius: var(--radius-pill); }\n.withIcon { padding-inline-start: calc(var(--badge-pad) - 2px); }\n.withDot { padding-inline-start: calc(var(--badge-pad) - 1px); }\n\n/* Flex all the way down: an inline box here would sit on a text baseline and push the label off centre. */\n.body { display: flex; align-items: center; }\n.content { position: relative; display: flex; width: max-content; align-items: center; gap: var(--badge-gap); }\n.label, .glyph { position: relative; display: inline-flex; align-items: center; }\n/* Trim the line box to cap height and baseline, so flex centring puts the letters on the badge's optical centre at every size. */\n.text { display: block; text-box: trim-both cap alphabetic; }\n\n.icon {\n  position: relative;\n  display: inline-flex;\n  flex: none;\n  align-items: center;\n  justify-content: center;\n  color: var(--badge-color);\n  line-height: 0;\n}\n.glyph > svg { display: block; width: var(--badge-icon); height: var(--badge-icon); }\n\n.dot {\n  display: block;\n  width: var(--badge-dot);\n  height: var(--badge-dot);\n  flex: none;\n  border-radius: 50%;\n  background: var(--badge-color);\n}\n\n.neutral { --badge-tone: var(--text-secondary); }\n.info { --badge-tone: var(--info, var(--tone-sky)); }\n.success { --badge-tone: var(--success); }\n.warning { --badge-tone: var(--warning); }\n.danger { --badge-tone: var(--danger); }\n/* Categorical colours (method tags, labels) override the tone. */\n.teal { --badge-tone: var(--tone-teal); }\n.sky { --badge-tone: var(--tone-sky); }\n.orchid { --badge-tone: var(--tone-orchid); }\n.violet { --badge-tone: var(--tone-violet); }\n.rose { --badge-tone: var(--tone-rose); }\n.amber { --badge-tone: var(--tone-amber); }\n/* Amber is the lightest hue, so light mode deepens its text a little further. */\n.warning, .amber { --badge-ink: 76%; }\n\n@media (prefers-reduced-motion: reduce) {\n  .badge { transition-duration: var(--duration-instant); }\n}\n"}],"docs":"Docs and live preview: https://uiarc.dev/components/badge","meta":{"tier":"free","kind":"component","tags":["label","status"],"docs":"https://uiarc.dev/components/badge","markdown":"https://uiarc.dev/components/badge/markdown"}}