"use client"; import { useLayoutEffect, useRef, useState, type ReactNode } from "react"; /* * Reaction Bar – like, comment, repost and share in one pill, in a single self-contained file. * Tapping an action colors its icon with a small pop, and its count rolls in digit by digit while the pill * glides to its new width. The styles are injected below, so no extra files are needed. */ export type ReactionKey = "like" | "comment" | "repost" | "share"; export type ReactionState = { count: number; active?: boolean }; export type ReactionBarProps = { /** Starting counts and whether each action is already taken */ reactions?: Partial>; /** Called after an action was toggled, with its new state */ onReact?: (key: ReactionKey, state: Required) => void; /** Hide actions you don't need */ actions?: ReactionKey[]; className?: string; }; const labels: Record = { like: ["Like", "likes"], comment: ["Comment", "comments"], repost: ["Repost", "reposts"], share: ["Share", "shares"], }; /** 1234 → "1.2K", like social apps – rounded so 999,950 reads "1M", not "1000K" */ const compact = new Intl.NumberFormat("en", { notation: "compact", maximumFractionDigits: 1 }); const exact = new Intl.NumberFormat("en"); const short = (n: number) => (n >= 10_000 && n < 1_000_000 ? compact.format(Math.round(n / 1000) * 1000) : compact.format(n)); /** Counts are whole, never negative – NaN, Infinity and the like count as 0 */ const clean = (n: unknown) => (typeof n === "number" && Number.isFinite(n) && n > 0 ? Math.floor(n) : 0); export function ReactionBar({ reactions = {}, onReact, actions: actionsProp = ["like", "comment", "repost", "share"], className }: ReactionBarProps) { // Only known actions, each once const actions = (Array.isArray(actionsProp) ? actionsProp : []).filter((k, i, all) => k in labels && all.indexOf(k) === i); const [state, setState] = useState(() => Object.fromEntries( (Object.keys(labels) as ReactionKey[]).map((k) => [k, { count: clean(reactions?.[k]?.count), active: reactions?.[k]?.active === true }]), ) as Record>, ); const [popped, setPopped] = useState>({}); const toggle = (key: ReactionKey) => { const cur = state[key]; const next = { active: !cur.active, count: Math.max(0, cur.count + (cur.active ? -1 : 1)) }; setState((s) => ({ ...s, [key]: next })); // A new key restarts the pop animation on every tap setPopped((p) => ({ ...p, [key]: (p[key] ?? 0) + 1 })); onReact?.(key, next); }; if (!actions.length) return null; return ( <>
{actions.map((key) => { const { count, active } = state[key]; const [verb, noun] = labels[key]; return ( ); })}
); } /** The count glides to its new width; each changed digit rolls in from below */ function Count({ value }: { value: number }) { const text = value > 0 ? short(value) : ""; const outer = useRef(null); const inner = useRef(null); const first = useRef(true); useLayoutEffect(() => { const o = outer.current; const i = inner.current; if (!o || !i) return; o.style.width = `${i.offsetWidth}px`; if (first.current) { first.current = false; requestAnimationFrame(() => o.setAttribute("data-smooth", "")); } }, [text]); return ( ); } /* ---------- Icons (stroke, rounded) ---------- */ const Svg = ({ children }: { children: ReactNode }) => ( ); const icons: Record = { like: ( ), comment: ( ), repost: ( ), share: ( ), }; /* ---------- Styles ---------- */ const css = /* css */ ` .ui-rb { --ui-rb-ink: #404040; --ui-rb-like: #ec4899; --ui-rb-comment: #2f6bff; --ui-rb-repost: #8b5cf6; --ui-rb-share: #171717; --ui-rb-ease: cubic-bezier(0.85, 0, 0.15, 1); --ui-rb-soft: cubic-bezier(0.22, 1, 0.36, 1); display: inline-flex; align-items: center; flex-wrap: wrap; justify-content: center; gap: 2px; max-width: 100%; padding: 4px 10px; /* Half its one-row height – a pill; if huge counts wrap it to a second row it stays a rounded card */ border-radius: 25px; background: #fff; box-shadow: 0 0 0 1px #ececec, 0 1px 2px rgba(0, 0, 0, 0.04), 0 4px 12px rgba(0, 0, 0, 0.03); font-family: "Inter", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; -webkit-font-smoothing: antialiased; } .ui-rb *, .ui-rb *::before, .ui-rb *::after { box-sizing: border-box; } .ui-rb__btn { position: relative; display: inline-flex; align-items: center; min-width: 42px; height: 42px; margin: 0; padding: 0 10px; border: 0; border-radius: 999px; background: transparent; font: inherit; color: var(--ui-rb-ink); cursor: pointer; transition: background-color 160ms ease; -webkit-tap-highlight-color: transparent; } .ui-rb__btn:hover { background: #f5f5f5; } .ui-rb__btn:focus-visible { outline: 2px solid var(--ui-rb-comment); outline-offset: 2px; } .ui-rb__btn[data-kind="like"] { --ui-rb-tone: var(--ui-rb-like); } .ui-rb__btn[data-kind="comment"] { --ui-rb-tone: var(--ui-rb-comment); } .ui-rb__btn[data-kind="repost"] { --ui-rb-tone: var(--ui-rb-repost); } .ui-rb__btn[data-kind="share"] { --ui-rb-tone: var(--ui-rb-share); } .ui-rb__icon { display: grid; place-items: center; flex: none; } .ui-rb__icon svg { transition: color 200ms ease; } .ui-rb__fill { fill: transparent; transition: fill 200ms ease; } .ui-rb__btn[aria-pressed="true"] { color: var(--ui-rb-tone); } .ui-rb__btn[aria-pressed="true"] .ui-rb__fill { fill: currentColor; } /* Press pop: shrinks, springs past full size, settles */ .ui-rb__icon[data-pop] { animation: ui-rb-pop 320ms cubic-bezier(0.65, 0, 0.35, 1); } /* Count: the width glides, digits roll */ .ui-rb__count { display: inline-block; overflow: hidden; width: 0; height: 20px; } .ui-rb__count[data-smooth] { transition: width 400ms var(--ui-rb-ease); } .ui-rb__digits { display: inline-flex; padding-left: 6px; font-size: 14px; font-weight: 500; line-height: 20px; font-variant-numeric: tabular-nums; white-space: nowrap; } .ui-rb__digits:empty { padding-left: 0; } .ui-rb__digit { display: inline-block; animation: ui-rb-roll 400ms var(--ui-rb-ease) both; } @keyframes ui-rb-pop { 0% { transform: scale(1); } 35% { transform: scale(0.75); } 70% { transform: scale(1.12); } 100% { transform: scale(1); } } @keyframes ui-rb-roll { from { opacity: 0; transform: translateY(70%); } } @media (prefers-reduced-motion: reduce) { .ui-rb__icon[data-pop], .ui-rb__digit { animation: none; } .ui-rb__count[data-smooth] { transition: none; } } /* Dark mode: follows a .dark class or data-theme="dark" on any ancestor, e.g. */ :where(.dark, [data-theme="dark"]) .ui-rb { --ui-rb-ink: #c4c4c4; --ui-rb-like: #f472b6; --ui-rb-comment: #6b95ff; --ui-rb-repost: #a78bfa; --ui-rb-share: #ededed; background: #232326; box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.08), 0 1px 2px rgba(0, 0, 0, 0.4), 0 4px 12px rgba(0, 0, 0, 0.35); } :where(.dark, [data-theme="dark"]) .ui-rb__btn:hover { background: rgba(255, 255, 255, 0.06); } `;