{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "count-badge",
  "type": "registry:ui",
  "title": "Count Badge",
  "description": "A count that rolls its digits, pops when it rises and caps at 99+.",
  "categories": [
    "notifications"
  ],
  "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/count-badge.tsx",
      "content": "\"use client\";\nimport NumberFlow from \"@number-flow/react\";\nimport { AnimatePresence, motion, useAnimate, useReducedMotion } from \"motion/react\";\nimport { useEffect, useRef } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { ease, spring } from \"@/lib/motion\";\n\ntype Size = \"sm\" | \"md\";\ntype Tone = \"solid\" | \"danger\" | \"muted\";\ntype Position = \"inline\" | \"top-right\" | \"bottom-right\";\n\nconst expo = `cubic-bezier(${ease.out.join(\",\")})`;\nconst digitTiming = {\n  transformTiming: { duration: 420, easing: expo },\n  spinTiming: { duration: 420, easing: expo },\n  opacityTiming: { duration: 160, easing: \"ease-out\" },\n};\n\nexport type CountBadgeProps = Omit<React.ComponentProps<\"span\">, \"children\"> & {\n  /** The number to show. Nothing renders at 0 unless `showZero`. */\n  count: number;\n  /** Above this, the badge reads \"99+\" while the accessible label keeps the real number. */\n  max?: number;\n  /** A plain dot instead of digits, for \"something new\" without a number. */\n  dot?: boolean;\n  showZero?: boolean;\n  size?: Size;\n  /** Foreground fill (the quiet default), danger for things that need action, or muted for counts in a list. */\n  tone?: Tone;\n  /** Where it sits on its anchor. Defaults to top-right when it wraps children, inline otherwise. */\n  position?: Position;\n  /** Pull the badge in to sit on the edge of a round anchor like an avatar, not its empty bounding-box corner. */\n  overlap?: \"rectangular\" | \"circular\";\n  /** What a screen reader hears. Rendered visually hidden inside the badge, so a badge inside a button becomes part of its name. */\n  label?: (count: number) => string;\n  /** Also announce changes through a polite live region. For counts that change while the person is elsewhere. */\n  announce?: boolean;\n  /** The element the badge sits on: an icon button, an avatar. */\n  children?: React.ReactNode;\n};\n\nconst defaultLabel = (n: number) => `${n} new`;\n\nconst sizes = {\n  sm: { pill: \"h-4 min-w-4 px-1 text-[10px]\", dot: \"size-2\" },\n  md: { pill: \"h-[18px] min-w-[18px] px-[5px] text-[10.5px]\", dot: \"size-2.5\" },\n} as const;\n\nconst tones = {\n  solid: \"bg-fg text-frame\",\n  danger: \"bg-danger text-frame\",\n  muted: \"bg-hover text-fg-2 shadow-[inset_0_0_0_1px_var(--line-2)]\",\n} as const;\n\n// Anchored badges are centered on the anchor's corner. On a circle the corner is\n// empty space, so the center moves in to the rim at 45°: r(1 − 1/√2) ≈ 14.6%.\nconst anchors = {\n  rectangular: { \"top-right\": \"right-0 top-0 translate-x-1/2 -translate-y-1/2\", \"bottom-right\": \"right-0 bottom-0 translate-x-1/2 translate-y-1/2\" },\n  circular: { \"top-right\": \"right-[14.6%] top-[14.6%] translate-x-1/2 -translate-y-1/2\", \"bottom-right\": \"right-[14.6%] bottom-[14.6%] translate-x-1/2 translate-y-1/2\" },\n} as const;\n\nexport function CountBadge({\n  count,\n  max = 99,\n  dot = false,\n  showZero = false,\n  size = \"md\",\n  tone = \"solid\",\n  position,\n  overlap = \"rectangular\",\n  label = defaultLabel,\n  announce = false,\n  children,\n  className,\n  ...rest\n}: CountBadgeProps) {\n  const reduce = useReducedMotion();\n  const [scope, animate] = useAnimate<HTMLSpanElement>();\n  const [ringScope, animateRing] = useAnimate<HTMLSpanElement>();\n  const previous = useRef(count);\n  const where: Position = position ?? (children ? \"top-right\" : \"inline\");\n  const anchored = where !== \"inline\";\n  const visible = dot ? count > 0 : count > 0 || showZero;\n  const over = count > max;\n\n  // A rise the person didn't cause gets a pop: up 18% in 90ms, then a spring back.\n  // Falling counts (they read something) and the first appearance don't pop.\n  useEffect(() => {\n    const was = previous.current;\n    previous.current = count;\n    if (reduce || count <= was || was <= 0) return;\n    if (scope.current) {\n      animate(scope.current, { scale: 1.18 }, { duration: 0.09, ease: ease.out }).then(() => {\n        if (scope.current) animate(scope.current, { scale: 1 }, spring.pop);\n      });\n    }\n    // A dot has no digits to roll, so it sends out one soft ring instead.\n    if (dot && ringScope.current) {\n      animateRing(ringScope.current, { scale: [1, 2.6], opacity: [0.5, 0] }, { duration: 0.7, ease: ease.out });\n    }\n  }, [count, dot, reduce, animate, animateRing, scope, ringScope]);\n\n  const text = visible ? label(count) : \"\";\n\n  const badge = (\n    <span\n      data-slot=\"count-badge\"\n      data-size={size}\n      data-tone={tone}\n      data-state={visible ? \"visible\" : \"hidden\"}\n      className={cn(\n        \"pointer-events-none select-none\",\n        anchored ? cn(\"absolute z-[1]\", anchors[overlap][where]) : \"relative inline-flex align-middle\",\n        !children && className,\n      )}\n      {...(children ? {} : rest)}\n    >\n      <AnimatePresence initial={false}>\n        {visible && (\n          <motion.span\n            key=\"badge\"\n            aria-hidden\n            className=\"relative block\"\n            initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.4, filter: \"blur(2px)\" }}\n            animate={{ opacity: 1, scale: 1, filter: \"blur(0px)\" }}\n            exit={reduce ? { opacity: 0, transition: { duration: 0.12 } } : { opacity: 0, scale: 0.4, filter: \"blur(2px)\", transition: { duration: 0.16, ease: ease.in } }}\n            transition={reduce ? { duration: 0.15 } : spring.pop}\n          >\n            {dot ? (\n              <span ref={scope} className=\"relative block\">\n                <span ref={ringScope} className={cn(\"absolute inset-0 rounded-full opacity-0\", tones[tone].split(\" \")[0])} />\n                <span\n                  className={cn(\n                    \"relative block rounded-full\",\n                    sizes[size].dot,\n                    tones[tone],\n                    anchored && \"shadow-[0_0_0_2px_var(--badge-cutout,var(--frame))]\",\n                  )}\n                />\n              </span>\n            ) : (\n              <span\n                ref={scope}\n                className={cn(\n                  \"inline-flex items-center justify-center overflow-hidden rounded-full font-medium leading-none tracking-normal tabular\",\n                  sizes[size].pill,\n                  tones[tone],\n                  anchored && \"shadow-[0_0_0_2px_var(--badge-cutout,var(--frame))]\",\n                )}\n              >\n                <NumberFlow\n                  value={over ? max : count}\n                  suffix={over ? \"+\" : undefined}\n                  {...digitTiming}\n                />\n              </span>\n            )}\n          </motion.span>\n        )}\n      </AnimatePresence>\n      <span className=\"sr-only\" aria-live={announce ? \"polite\" : undefined}>\n        {text}\n      </span>\n    </span>\n  );\n\n  if (!children) return badge;\n  return (\n    <span data-slot=\"count-badge-anchor\" className={cn(\"relative inline-flex shrink-0 align-middle\", className)} {...rest}>\n      {children}\n      {badge}\n    </span>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/count-badge"
}
