{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "badge",
  "type": "registry:ui",
  "title": "Badge",
  "description": "Status and count labels whose text, width and tone change in place.",
  "categories": [
    "display"
  ],
  "dependencies": [
    "@number-flow/react",
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/motion.json"
  ],
  "files": [
    {
      "path": "components/ui/badge.tsx",
      "content": "\"use client\";\nimport NumberFlow from \"@number-flow/react\";\nimport { AnimatePresence, motion, useAnimate, useReducedMotion } from \"motion/react\";\nimport { useEffect, useLayoutEffect, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { ease, spring } from \"@/lib/motion\";\n\nexport type BadgeTone = \"neutral\" | \"success\" | \"warning\" | \"danger\" | \"info\";\nexport type BadgeVariant = \"soft\" | \"outline\" | \"solid\";\nexport type BadgeSize = \"sm\" | \"md\" | \"lg\";\n\n// Soft is the default: a wash of the tone with the tone as text. Outline keeps the page quiet\n// in dense tables. Solid is for the one badge on screen that has to be seen.\nconst tones: Record<BadgeVariant, Record<BadgeTone, string>> = {\n  soft: {\n    neutral: \"bg-fg/[0.07] text-fg-2\",\n    success: \"bg-success-soft text-success\",\n    warning: \"bg-warning-soft text-warning\",\n    danger: \"bg-danger-soft text-danger\",\n    info: \"bg-info-soft text-info\",\n  },\n  outline: {\n    neutral: \"border-line-2 text-fg-2\",\n    success: \"border-success/35 text-success\",\n    warning: \"border-warning/35 text-warning\",\n    danger: \"border-danger/35 text-danger\",\n    info: \"border-info/35 text-info\",\n  },\n  // Text on the solid fills is picked per theme for contrast: the pale dark-theme fills take the\n  // frame color; in light, green and amber are too pale for it and take the foreground instead.\n  solid: {\n    neutral: \"bg-fg text-frame\",\n    success: \"bg-success text-frame in-data-[theme=light]:text-fg\",\n    warning: \"bg-warning text-frame in-data-[theme=light]:text-fg\",\n    danger: \"bg-danger text-frame\",\n    info: \"bg-info text-frame\",\n  },\n};\n\nconst sizes: Record<BadgeSize, string> = {\n  sm: \"h-[18px] rounded-[5px] text-[11px]\",\n  md: \"h-5 rounded-md text-[11.5px]\",\n  lg: \"h-6 rounded-md text-[12.5px]\",\n};\n\n// Padding lives on the inner span, so the width the badge glides to includes it.\nconst pads: Record<BadgeSize, string> = { sm: \"gap-1 px-1.5\", md: \"gap-1.5 px-1.5\", lg: \"gap-1.5 px-2\" };\n\n/* -------------------------------------------------------------------------------------------------\n * Dot\n * -----------------------------------------------------------------------------------------------*/\n\nexport type BadgeDotProps = React.ComponentProps<\"span\"> & {\n  /** \"current\" follows the text color, for dots on solid fills. */\n  tone?: BadgeTone | \"current\";\n  /** A soft ring pulses out from the dot: for live and in-progress states only. */\n  pulse?: boolean;\n  /** Diameter in px. */\n  size?: number;\n  /** Spoken instead of shown, e.g. \"Unread\". Leave empty when a visible label says it. */\n  label?: string;\n};\n\nconst dotTone: Record<BadgeTone | \"current\", string> = {\n  current: \"bg-current\",\n  neutral: \"bg-fg-3\",\n  success: \"bg-success\",\n  warning: \"bg-warning\",\n  danger: \"bg-danger\",\n  info: \"bg-info\",\n};\n\n/** A status dot on its own: unread markers, service health, the dot inside a badge. */\nexport function BadgeDot({ tone = \"neutral\", pulse = false, size = 6, label, className, style, ...rest }: BadgeDotProps) {\n  return (\n    <span\n      role={label ? \"img\" : undefined}\n      aria-label={label}\n      aria-hidden={label ? undefined : true}\n      data-tone={tone}\n      data-pulse={pulse || undefined}\n      style={{ width: size, height: size, ...style }}\n      className={cn(\"relative inline-block shrink-0 rounded-full transition-[background-color] duration-200\", dotTone[tone], className)}\n      {...rest}\n    >\n      {pulse && <span className={cn(\"absolute inset-0 animate-ping-soft rounded-full motion-reduce:hidden\", dotTone[tone])} />}\n    </span>\n  );\n}\n\n/* -------------------------------------------------------------------------------------------------\n * Badge\n * -----------------------------------------------------------------------------------------------*/\n\nexport type BadgeProps = React.ComponentProps<\"span\"> & {\n  tone?: BadgeTone;\n  variant?: BadgeVariant;\n  size?: BadgeSize;\n  /** A leading dot in the tone. */\n  dot?: boolean;\n  /** Pulse the dot, for states that are live or still running. */\n  pulse?: boolean;\n  /** A leading icon, drawn at 12px (14px in lg). Replaces the dot. */\n  icon?: React.ReactNode;\n};\n\n/**\n * A short status or label. When the label changes (Queued → Building → Ready) the old word\n * lifts out, the new one rises in, the width glides to fit and the color follows, so a\n * status that updates in place is noticed without a flash.\n */\nexport function Badge({ tone = \"neutral\", variant = \"soft\", size = \"md\", dot = false, pulse = false, icon, className, children, ...rest }: BadgeProps) {\n  const reduce = useReducedMotion();\n  const inner = useRef<HTMLSpanElement>(null);\n  const [width, setWidth] = useState<number | null>(null);\n\n  // Track the content's width so the badge can glide to it instead of snapping.\n  useLayoutEffect(() => {\n    const el = inner.current;\n    if (!el) return;\n    const ro = new ResizeObserver(() => setWidth(el.offsetWidth));\n    ro.observe(el);\n    return () => ro.disconnect();\n  }, []);\n\n  const swapKey = typeof children === \"string\" || typeof children === \"number\" ? String(children) : \"node\";\n  const lead = icon ? (\n    <span aria-hidden className={cn(\"grid shrink-0 place-items-center [&_svg]:size-full\", size === \"lg\" ? \"size-3.5\" : \"size-3\")}>\n      {icon}\n    </span>\n  ) : dot || pulse ? (\n    <BadgeDot tone={variant === \"solid\" ? \"current\" : tone} pulse={pulse} size={size === \"lg\" ? 6 : 5} />\n  ) : null;\n\n  return (\n    <motion.span\n      data-tone={tone}\n      data-variant={variant}\n      data-size={size}\n      initial={false}\n      animate={width == null ? undefined : { width }}\n      transition={reduce ? { duration: 0 } : spring.snappy}\n      className={cn(\n        \"relative inline-flex shrink-0 select-none items-center overflow-hidden whitespace-nowrap align-middle font-medium leading-none tracking-[0.005em]\",\n        \"transition-[background-color,border-color,color] duration-200 ease-out\",\n        variant === \"outline\" ? \"border\" : \"border border-transparent\",\n        tones[variant][tone],\n        sizes[size],\n        className,\n      )}\n      {...(rest as React.ComponentProps<typeof motion.span>)}\n    >\n      <span ref={inner} className={cn(\"relative inline-flex items-center\", pads[size])}>\n        {lead}\n        <AnimatePresence mode=\"popLayout\" initial={false}>\n          <motion.span\n            key={swapKey}\n            className=\"tabular\"\n            initial={reduce ? { opacity: 0 } : { opacity: 0, y: 5, filter: \"blur(2px)\" }}\n            animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n            exit={reduce ? { opacity: 0 } : { opacity: 0, y: -5, filter: \"blur(2px)\", transition: { duration: 0.14, ease: ease.out } }}\n            transition={{ duration: reduce ? 0.12 : 0.22, ease: ease.out }}\n          >\n            {children}\n          </motion.span>\n        </AnimatePresence>\n      </span>\n    </motion.span>\n  );\n}\n\n/* -------------------------------------------------------------------------------------------------\n * Count\n * -----------------------------------------------------------------------------------------------*/\n\nexport type BadgeCountProps = Omit<React.ComponentProps<\"span\">, \"children\"> & {\n  count: number;\n  /** Above this the badge reads \"99+\". */\n  max?: number;\n  /** Keep the badge at 0 instead of letting it go. */\n  showZero?: boolean;\n  tone?: BadgeTone;\n  variant?: \"soft\" | \"solid\";\n  size?: \"sm\" | \"md\";\n  /** What the number counts, for screen readers: (n) => `${n} unread`. */\n  label?: (count: number) => string;\n  /**\n   * Pin the count to the top-right corner of this element (an icon button, an avatar). The\n   * count gets a ring in `--badge-ring`, the surface behind it, `--frame` by default.\n   */\n  children?: React.ReactNode;\n};\n\n/**\n * A number in a pill. Digits roll rather than swap, the pill pops once when the count goes up,\n * and it scales in from nothing and back out when it reaches zero.\n */\nexport function BadgeCount({\n  count,\n  max = 99,\n  showZero = false,\n  tone = \"neutral\",\n  variant = \"solid\",\n  size = \"md\",\n  label = (n) => String(n),\n  className,\n  children,\n  ...rest\n}: BadgeCountProps) {\n  const reduce = useReducedMotion();\n  const [scope, animate] = useAnimate<HTMLSpanElement>();\n  const prev = useRef(count);\n  const visible = count > 0 || showZero;\n  const over = count > max;\n\n  // A single small pop when the number grows: the signal that something new arrived.\n  useEffect(() => {\n    const grew = count > prev.current;\n    prev.current = count;\n    if (!grew || reduce || !scope.current) return;\n    animate(scope.current, { scale: [1, 1.14, 1] }, { duration: 0.32, ease: ease.out });\n  }, [count, reduce, animate, scope]);\n\n  const pill = (\n    <AnimatePresence initial={false}>\n      {visible && (\n        <motion.span\n          key=\"count\"\n          initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.4 }}\n          animate={{ opacity: 1, scale: 1 }}\n          exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.4, transition: { duration: 0.14, ease: ease.out } }}\n          transition={reduce ? { duration: 0.12 } : spring.pop}\n          className={cn(!!children && \"pointer-events-none absolute -right-1 -top-1 z-10\")}\n        >\n          <span\n            ref={scope}\n            data-tone={tone}\n            data-variant={variant}\n            className={cn(\n              \"inline-flex items-center justify-center rounded-full font-medium leading-none tabular\",\n              \"transition-[background-color,color] duration-200\",\n              size === \"sm\" ? \"h-4 min-w-4 px-1 text-[10px]\" : \"h-[18px] min-w-[18px] px-[5px] text-[11px]\",\n              tones[variant][tone],\n              !!children && \"shadow-[0_0_0_2px_var(--badge-ring,var(--frame))]\",\n              className,\n            )}\n            {...rest}\n          >\n            <span aria-hidden className=\"flex items-center\">\n              <NumberFlow value={Math.min(count, max)} className=\"leading-none\" />\n              {over && <span className=\"-mr-px\">+</span>}\n            </span>\n            <span className=\"sr-only\">{label(count)}</span>\n          </span>\n        </motion.span>\n      )}\n    </AnimatePresence>\n  );\n\n  if (!children) return pill;\n  return (\n    <span className=\"relative inline-flex\">\n      {children}\n      {pill}\n    </span>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/badge"
}
