{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"reaction-bar","type":"registry:ui","title":"Reaction Bar","description":"Like, comment, repost and share in one pill – icons pop and fill, counts roll in digit by digit","dependencies":[],"files":[{"path":"registry/reaction-bar/reaction-bar.tsx","type":"registry:ui","content":"\"use client\";\n\nimport { useLayoutEffect, useRef, useState, type ReactNode } from \"react\";\n\n/*\n * Reaction Bar – like, comment, repost and share in one pill, in a single self-contained file.\n * Tapping an action colors its icon with a small pop, and its count rolls in digit by digit while the pill\n * glides to its new width. The styles are injected below, so no extra files are needed.\n */\n\nexport type ReactionKey = \"like\" | \"comment\" | \"repost\" | \"share\";\n\nexport type ReactionState = { count: number; active?: boolean };\n\nexport type ReactionBarProps = {\n  /** Starting counts and whether each action is already taken */\n  reactions?: Partial<Record<ReactionKey, ReactionState>>;\n  /** Called after an action was toggled, with its new state */\n  onReact?: (key: ReactionKey, state: Required<ReactionState>) => void;\n  /** Hide actions you don't need */\n  actions?: ReactionKey[];\n  className?: string;\n};\n\nconst labels: Record<ReactionKey, [string, string]> = {\n  like: [\"Like\", \"likes\"],\n  comment: [\"Comment\", \"comments\"],\n  repost: [\"Repost\", \"reposts\"],\n  share: [\"Share\", \"shares\"],\n};\n\n/** 1234 → \"1.2K\", like social apps – rounded so 999,950 reads \"1M\", not \"1000K\" */\nconst compact = new Intl.NumberFormat(\"en\", { notation: \"compact\", maximumFractionDigits: 1 });\nconst exact = new Intl.NumberFormat(\"en\");\nconst short = (n: number) => (n >= 10_000 && n < 1_000_000 ? compact.format(Math.round(n / 1000) * 1000) : compact.format(n));\n/** Counts are whole, never negative – NaN, Infinity and the like count as 0 */\nconst clean = (n: unknown) => (typeof n === \"number\" && Number.isFinite(n) && n > 0 ? Math.floor(n) : 0);\n\nexport function ReactionBar({ reactions = {}, onReact, actions: actionsProp = [\"like\", \"comment\", \"repost\", \"share\"], className }: ReactionBarProps) {\n  // Only known actions, each once\n  const actions = (Array.isArray(actionsProp) ? actionsProp : []).filter((k, i, all) => k in labels && all.indexOf(k) === i);\n  const [state, setState] = useState(() =>\n    Object.fromEntries(\n      (Object.keys(labels) as ReactionKey[]).map((k) => [k, { count: clean(reactions?.[k]?.count), active: reactions?.[k]?.active === true }]),\n    ) as Record<ReactionKey, Required<ReactionState>>,\n  );\n  const [popped, setPopped] = useState<Record<string, number>>({});\n\n  const toggle = (key: ReactionKey) => {\n    const cur = state[key];\n    const next = { active: !cur.active, count: Math.max(0, cur.count + (cur.active ? -1 : 1)) };\n    setState((s) => ({ ...s, [key]: next }));\n    // A new key restarts the pop animation on every tap\n    setPopped((p) => ({ ...p, [key]: (p[key] ?? 0) + 1 }));\n    onReact?.(key, next);\n  };\n\n  if (!actions.length) return null;\n\n  return (\n    <>\n      <style href=\"ui-reaction-bar\" precedence=\"default\">\n        {css}\n      </style>\n      <div className={[\"ui-rb\", className].filter(Boolean).join(\" \")} role=\"group\" aria-label=\"Reactions\">\n        {actions.map((key) => {\n          const { count, active } = state[key];\n          const [verb, noun] = labels[key];\n          return (\n            <button\n              key={key}\n              type=\"button\"\n              className=\"ui-rb__btn\"\n              data-kind={key}\n              aria-pressed={active}\n              aria-label={`${verb}${count ? `, ${exact.format(count)} ${count === 1 ? noun.slice(0, -1) : noun}` : \"\"}`}\n              title={count >= 1000 ? `${exact.format(count)} ${noun}` : undefined}\n              onClick={() => toggle(key)}\n            >\n              <span className=\"ui-rb__icon\" key={popped[key] ?? 0} data-pop={popped[key] ? true : undefined}>\n                {icons[key]}\n              </span>\n              <Count value={count} />\n            </button>\n          );\n        })}\n      </div>\n    </>\n  );\n}\n\n/** The count glides to its new width; each changed digit rolls in from below */\nfunction Count({ value }: { value: number }) {\n  const text = value > 0 ? short(value) : \"\";\n  const outer = useRef<HTMLSpanElement>(null);\n  const inner = useRef<HTMLSpanElement>(null);\n  const first = useRef(true);\n\n  useLayoutEffect(() => {\n    const o = outer.current;\n    const i = inner.current;\n    if (!o || !i) return;\n    o.style.width = `${i.offsetWidth}px`;\n    if (first.current) {\n      first.current = false;\n      requestAnimationFrame(() => o.setAttribute(\"data-smooth\", \"\"));\n    }\n  }, [text]);\n\n  return (\n    <span className=\"ui-rb__count\" ref={outer} aria-hidden=\"true\">\n      <span className=\"ui-rb__digits\" ref={inner}>\n        {text.split(\"\").map((d, i) => (\n          <span key={`${text.length - i}-${d}`} className=\"ui-rb__digit\">\n            {d}\n          </span>\n        ))}\n      </span>\n    </span>\n  );\n}\n\n/* ---------- Icons (stroke, rounded) ---------- */\n\nconst Svg = ({ children }: { children: ReactNode }) => (\n  <svg viewBox=\"0 0 24 24\" width=\"22\" height=\"22\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.6\" strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden=\"true\">\n    {children}\n  </svg>\n);\n\nconst icons: Record<ReactionKey, ReactNode> = {\n  like: (\n    <Svg>\n      <path className=\"ui-rb__fill\" d=\"M19.46 4c-2.68-1.65-5.02-.99-6.43.07-.57.43-.86.65-1.03.65s-.46-.22-1.03-.65C9.56 3.01 7.22 2.35 4.54 4 1.02 6.15.22 13.27 8.34 19.28 9.89 20.43 10.66 21 12 21s2.11-.57 3.66-1.72C23.78 13.27 22.98 6.15 19.46 4Z\" />\n    </Svg>\n  ),\n  comment: (\n    <Svg>\n      <path className=\"ui-rb__fill\" d=\"M12 21.5c5.52 0 10-4.25 10-9.5S17.52 2.5 12 2.5 2 6.75 2 12c0 1.36.3 2.65.84 3.82.15.31.2.66.1 1l-.6 2.23a1.08 1.08 0 0 0 1.6 1.59l2.22-.6c.34-.09.69-.04 1 .1 1.17.55 2.46.86 4.84.86Z\" />\n    </Svg>\n  ),\n  repost: (\n    <Svg>\n      <path d=\"M16.5 3.5 19 6l-2.5 2.5\" />\n      <path d=\"M19 6H8a4 4 0 0 0-4 4v1\" />\n      <path d=\"M7.5 20.5 5 18l2.5-2.5\" />\n      <path d=\"M5 18h11a4 4 0 0 0 4-4v-1\" />\n    </Svg>\n  ),\n  share: (\n    <Svg>\n      <path d=\"M21 3 10.5 13.5\" />\n      <path d=\"M21 3 14.6 20.6a.5.5 0 0 1-.93.05L10.5 13.5 3.35 10.33a.5.5 0 0 1 .05-.93Z\" />\n    </Svg>\n  ),\n};\n\n/* ---------- Styles ---------- */\n\nconst css = /* css */ `\n.ui-rb {\n  --ui-rb-ink: var(--aui-ink, #404040);\n  --ui-rb-like: var(--aui-pink, #ee3e9e);\n  --ui-rb-comment: var(--aui-blue, #4fb0fe);\n  --ui-rb-focus: var(--aui-focus, rgba(0, 96, 226, 0.7));\n  --ui-rb-repost: var(--aui-purple, #a651f0);\n  --ui-rb-share: var(--aui-ink, #171717);\n  --ui-rb-ease: cubic-bezier(0.85, 0, 0.15, 1);\n  --ui-rb-soft: var(--aui-ease, cubic-bezier(0.22, 1, 0.36, 1));\n  display: inline-flex;\n  align-items: center;\n  flex-wrap: wrap;\n  justify-content: center;\n  gap: 2px;\n  max-width: 100%;\n  padding: 4px 10px;\n  /* Half its one-row height – a pill; if huge counts wrap it to a second row it stays a rounded card */\n  border-radius: 25px;\n  background: var(--aui-surface, #fff);\n  box-shadow: var(--aui-shadow-card, 0 0 0 1px var(--aui-border, #ececec), 0 1px 2px rgba(0, 0, 0, 0.04), 0 4px 12px rgba(0, 0, 0, 0.03));\n  font-family: var(--aui-font, \"Inter\", ui-sans-serif, system-ui, -apple-system, \"Segoe UI\", Roboto, sans-serif);\n  -webkit-font-smoothing: antialiased;\n}\n.ui-rb *, .ui-rb *::before, .ui-rb *::after { box-sizing: border-box; }\n\n.ui-rb__btn {\n  position: relative;\n  display: inline-flex;\n  align-items: center;\n  min-width: 42px;\n  height: 42px;\n  margin: 0;\n  padding: 0 10px;\n  border: 0;\n  border-radius: 999px;\n  background: transparent;\n  font: inherit;\n  color: var(--ui-rb-ink);\n  cursor: pointer;\n  transition: background-color 160ms ease;\n  -webkit-tap-highlight-color: transparent;\n}\n.ui-rb__btn:hover { background: var(--aui-subtle, #f5f5f5); }\n.ui-rb__btn:focus-visible { outline: 2px solid var(--ui-rb-focus); outline-offset: 2px; }\n.ui-rb__btn[data-kind=\"like\"] { --ui-rb-tone: var(--ui-rb-like); }\n.ui-rb__btn[data-kind=\"comment\"] { --ui-rb-tone: var(--ui-rb-comment); }\n.ui-rb__btn[data-kind=\"repost\"] { --ui-rb-tone: var(--ui-rb-repost); }\n.ui-rb__btn[data-kind=\"share\"] { --ui-rb-tone: var(--ui-rb-share); }\n\n.ui-rb__icon { display: grid; place-items: center; flex: none; }\n.ui-rb__icon svg { transition: color 200ms ease; }\n.ui-rb__fill { fill: transparent; transition: fill 200ms ease; }\n.ui-rb__btn[aria-pressed=\"true\"] { color: var(--ui-rb-tone); }\n.ui-rb__btn[aria-pressed=\"true\"] .ui-rb__fill { fill: currentColor; }\n/* Press pop: shrinks, springs past full size, settles */\n.ui-rb__icon[data-pop] { animation: ui-rb-pop 320ms cubic-bezier(0.65, 0, 0.35, 1); }\n\n/* Count: the width glides, digits roll */\n.ui-rb__count {\n  display: inline-block;\n  overflow: hidden;\n  width: 0;\n  height: 20px;\n}\n.ui-rb__count[data-smooth] { transition: width 400ms var(--ui-rb-ease); }\n.ui-rb__digits {\n  display: inline-flex;\n  padding-left: 6px;\n  font-size: 14px;\n  font-weight: 500;\n  line-height: 20px;\n  font-variant-numeric: tabular-nums;\n  white-space: nowrap;\n}\n.ui-rb__digits:empty { padding-left: 0; }\n.ui-rb__digit { display: inline-block; animation: ui-rb-roll 400ms var(--ui-rb-ease) both; }\n\n@keyframes ui-rb-pop {\n  0% { transform: scale(1); }\n  35% { transform: scale(0.75); }\n  70% { transform: scale(1.12); }\n  100% { transform: scale(1); }\n}\n@keyframes ui-rb-roll {\n  from { opacity: 0; transform: translateY(70%); }\n}\n@media (prefers-reduced-motion: reduce) {\n  .ui-rb__icon[data-pop], .ui-rb__digit { animation: none; }\n  .ui-rb__count[data-smooth] { transition: none; }\n}\n\n/* Dark mode: follows a .dark class or data-theme=\"dark\" on any ancestor, e.g. <html> */\n:where(.dark, [data-theme=\"dark\"]) .ui-rb {\n  --ui-rb-ink: var(--aui-ink, #c4c4c4);\n  --ui-rb-like: var(--aui-pink, #f06db5);\n  --ui-rb-comment: var(--aui-blue, #4fb0fe);\n  --ui-rb-focus: var(--aui-focus, rgba(77, 141, 255, 0.8));\n  --ui-rb-repost: var(--aui-purple, #b98af0);\n  --ui-rb-share: var(--aui-ink, #ededed);\n  background: var(--aui-surface, #232326);\n  box-shadow: var(--aui-shadow-card, 0 0 0 1px var(--aui-border, rgba(255, 255, 255, 0.08)), 0 1px 2px rgba(0, 0, 0, 0.4), 0 4px 12px rgba(0, 0, 0, 0.35));\n}\n:where(.dark, [data-theme=\"dark\"]) .ui-rb__btn:hover { background: var(--aui-subtle, rgba(255, 255, 255, 0.06)); }\n`;\n"}]}