"use client";
import * as React from "react";
import { AnimatePresence, animate, motion, useMotionValue } from "motion/react";
export type ReactionGlyph = "heart" | "star" | "thumb" | "bookmark";
export interface ReactionButtonProps extends Omit<React.ComponentPropsWithoutRef<"button">, "children" | "onChange"> {
/** Controlled pressed state. When set, `count` is shown exactly as passed. */
pressed?: boolean;
/** Initial pressed state when uncontrolled. */
defaultPressed?: boolean;
/** Called on toggle with the next state and the count the button suggests (current ± 1). */
onPressedChange?: (pressed: boolean, count: number) => void;
/**
* Total number of reactions, including the viewer's own when pressed. Uncontrolled buttons
* add or remove the viewer's reaction on top of it.
*/
count?: number;
/** Accessible name; the formatted count is appended, e.g. "Like, 128". */
label?: string;
/** Built-in glyph, or a render function receiving the pressed state. */
icon?: ReactionGlyph | ((pressed: boolean) => React.ReactNode);
/** Colour of the active icon, ring and particles. */
accent?: string;
size?: "sm" | "md" | "lg";
/** Bordered capsule or a bare icon + count. */
variant?: "pill" | "ghost";
showCount?: boolean;
/** Ring shockwave and particle burst when the reaction is added. */
burst?: boolean;
/** Number of burst particles. */
particles?: number;
/** Formats the count. Digits roll individually; other characters stay put. */
formatCount?: (count: number) => string;
}
const GLYPHS: Record<ReactionGlyph, string> = {
heart:
"M12 20.4s-7.9-4.6-9.2-10.1C1.9 6.6 4.4 3.8 7.4 3.8c1.9 0 3.6 1 4.6 2.6 1-1.6 2.7-2.6 4.6-2.6 3 0 5.5 2.8 4.6 6.5-1.3 5.5-9.2 10.1-9.2 10.1Z",
star: "M12 3.4l2.6 5.5 6 .8-4.4 4.2 1.1 6-5.3-2.9-5.3 2.9 1.1-6-4.4-4.2 6-.8Z",
thumb: "M8 10.6 11.3 4c1.2-.3 2.6.5 2.4 2.1l-.5 3.3h4.9a2 2 0 0 1 2 2.4l-1.2 6.2a2 2 0 0 1-2 1.6H8ZM3.6 10.6H6v9H3.6Z",
bookmark: "M6.6 3.6h10.8c.6 0 1 .4 1 1v15.8L12 16.4l-6.4 4V4.6c0-.6.4-1 1-1Z",
};
const SIZES = {
sm: { button: "h-8 gap-1.5 px-2.5 text-xs", icon: 16 },
md: { button: "h-10 gap-2 px-3.5 text-sm", icon: 18 },
lg: { button: "h-12 gap-2.5 px-4 text-base", icon: 22 },
} as const;
const defaultFormat = (n: number) =>
new Intl.NumberFormat("en-US", { notation: Math.abs(n) >= 10000 ? "compact" : "standard", maximumFractionDigits: 1 }).format(n);
function subscribeReducedMotion(cb: () => void) {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
mq.addEventListener("change", cb);
return () => mq.removeEventListener("change", cb);
}
function usePrefersReducedMotion() {
return React.useSyncExternalStore(
subscribeReducedMotion,
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
() => false,
);
}
const digitVariants = {
enter: (dir: number) => ({ y: dir > 0 ? "100%" : "-100%", opacity: 0 }),
center: { y: "0%", opacity: 1 },
exit: (dir: number) => ({ y: dir > 0 ? "-100%" : "100%", opacity: 0 }),
};
/** One digit position. Only positions whose digit changed animate, in the direction of change. */
function DigitSlot({ digit, dir }: { digit: string; dir: number }) {
return (
<span className="relative inline-flex h-[1.2em] overflow-hidden leading-[1.2em]">
<AnimatePresence initial={false} custom={dir} mode="popLayout">
<motion.span
key={digit}
custom={dir}
variants={digitVariants}
initial="enter"
animate="center"
exit="exit"
transition={{ type: "spring", stiffness: 420, damping: 34 }}
className="inline-block"
>
{digit}
</motion.span>
</AnimatePresence>
</span>
);
}
/** Ring shockwave + particles. Mounted fresh (by key) for every activation. */
function Burst({ count, radius }: { count: number; radius: number }) {
const n = Math.max(0, Math.min(24, Math.round(count)));
return (
<span aria-hidden="true" className="pointer-events-none absolute left-1/2 top-1/2 size-0">
<motion.span
className="absolute rounded-full border-[var(--rb-accent)]"
style={{ width: radius * 2, height: radius * 2, left: -radius, top: -radius }}
initial={{ scale: 0.35, opacity: 0.9, borderWidth: radius * 0.45 }}
animate={{ scale: 1.7, opacity: 0, borderWidth: 0.5 }}
transition={{ duration: 0.55, ease: [0.22, 1, 0.36, 1] }}
/>
{Array.from({ length: n }, (_, i) => {
// Deterministic spread: even angles with a small alternating offset in angle and distance.
const angle = ((i / n) * 360 + (i % 2 ? 12 : -4)) * (Math.PI / 180);
const distance = radius * (i % 2 ? 1.25 : 1.6);
const dot = i % 3 === 0 ? 4 : 3;
return (
<motion.span
key={i}
className={`absolute rounded-full ${i % 2 ? "bg-[color-mix(in_oklab,var(--rb-accent)_55%,white)]" : "bg-[var(--rb-accent)]"}`}
style={{ width: dot, height: dot, left: -dot / 2, top: -dot / 2 }}
initial={{ x: 0, y: 0, scale: 1, opacity: 1 }}
animate={{ x: Math.cos(angle) * distance, y: Math.sin(angle) * distance, scale: 0.2, opacity: 0 }}
transition={{ duration: 0.6, delay: 0.04, ease: [0.16, 1, 0.3, 1] }}
/>
);
})}
</span>
);
}
/**
* A like / reaction toggle. Adding a reaction pops the icon with a spring and fires a ring
* shockwave and particle burst; the count rolls digit by digit up or down from its previous value.
*/
export const ReactionButton = React.forwardRef<HTMLButtonElement, ReactionButtonProps>(function ReactionButton(
{
pressed: pressedProp,
defaultPressed = false,
onPressedChange,
count = 0,
label = "Like",
icon = "heart",
accent = "#FF7448",
size = "md",
variant = "pill",
showCount = true,
burst = true,
particles = 8,
formatCount = defaultFormat,
className = "",
style,
onClick,
...props
},
ref,
) {
const reduceMotion = usePrefersReducedMotion();
const controlled = pressedProp !== undefined;
const [innerPressed, setInnerPressed] = React.useState(defaultPressed);
const pressed = controlled ? pressedProp : innerPressed;
// Uncontrolled: `count` is the total for `defaultPressed`; toggling adds or removes the viewer.
const total = controlled ? count : count + (pressed === defaultPressed ? 0 : pressed ? 1 : -1);
const formatted = formatCount(total);
// Remember the previous total during render so each change rolls in the right direction.
const [roll, setRoll] = React.useState({ value: total, dir: 1 });
if (roll.value !== total) setRoll({ value: total, dir: total > roll.value ? 1 : -1 });
const [burstId, setBurstId] = React.useState(0);
const iconScale = useMotionValue(1);
const sizes = SIZES[size] ?? SIZES.md;
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
onClick?.(event);
if (event.defaultPrevented) return;
const next = !pressed;
if (!controlled) setInnerPressed(next);
onPressedChange?.(next, total + (next ? 1 : -1));
if (reduceMotion) return;
if (next) {
// Squash, then release with velocity so the spring overshoots into a pop.
iconScale.set(0.55);
animate(iconScale, 1, { type: "spring", stiffness: 520, damping: 11, velocity: 10 });
if (burst) setBurstId((id) => id + 1);
} else {
iconScale.set(0.82);
animate(iconScale, 1, { type: "spring", stiffness: 600, damping: 22 });
}
};
const glyph =
typeof icon === "function" ? (
icon(pressed)
) : (
<svg viewBox="0 0 24 24" width={sizes.icon} height={sizes.icon} aria-hidden="true" className="overflow-visible">
<path
d={GLYPHS[icon] ?? GLYPHS.heart}
fill={pressed ? "currentColor" : "none"}
stroke="currentColor"
strokeWidth={1.6}
strokeLinejoin="round"
className="transition-[fill] duration-150"
/>
</svg>
);
const surface =
variant === "ghost"
? "border-transparent bg-transparent px-1.5 hover:bg-zinc-900/5 dark:hover:bg-white/5"
: pressed
? "border-[color-mix(in_oklab,var(--rb-accent)_45%,transparent)] bg-[color-mix(in_oklab,var(--rb-accent)_10%,transparent)]"
: "border-zinc-900/12 bg-white/60 hover:border-zinc-900/25 dark:border-white/12 dark:bg-white/[0.03] dark:hover:border-white/25";
const chars = formatted.split("");
return (
<button
ref={ref}
type="button"
aria-pressed={pressed}
aria-label={showCount ? `${label}, ${formatted}` : label}
data-state={pressed ? "on" : "off"}
onClick={handleClick}
className={`group relative inline-flex shrink-0 select-none items-center rounded-full border font-medium tabular-nums tracking-tight text-zinc-700 transition-[background-color,border-color,color] duration-200 [-webkit-tap-highlight-color:transparent] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--rb-accent)] disabled:cursor-not-allowed disabled:opacity-50 dark:text-zinc-300 ${sizes.button} ${surface} ${className}`}
style={{ ["--rb-accent" as string]: accent, ...style }}
{...props}
>
<span
aria-hidden="true"
className={`relative grid place-items-center transition-colors duration-150 ${pressed ? "text-[var(--rb-accent)]" : "group-hover:text-zinc-950 dark:group-hover:text-white"}`}
style={{ width: sizes.icon, height: sizes.icon }}
>
{burstId > 0 && !reduceMotion && <Burst key={burstId} count={particles} radius={sizes.icon} />}
<motion.span className="grid place-items-center" style={{ scale: iconScale }}>
{glyph}
</motion.span>
</span>
{showCount && (
<span aria-hidden="true" className={`inline-flex h-[1.2em] items-center leading-[1.2em] ${pressed ? "text-zinc-950 dark:text-white" : ""}`}>
{reduceMotion
? formatted
: // Keys count from the right so units, tens… keep their slot when the length changes.
chars.map((ch, i) => {
const key = chars.length - i;
return /\d/.test(ch) ? <DigitSlot key={key} digit={ch} dir={roll.dir} /> : <span key={key}>{ch}</span>;
})}
</span>
)}
</button>
);
});