A reaction pill that pops the emoji, pulses a ring, fires a deterministic six-dot burst on activation, and rolls the count up optimistically — silent on un-react.
npx shadcn@latest add @paragon/reaction-buttonAlso installs: digit-roll
"use client";
import * as React from "react";
import { motion, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";
import { DigitRoll } from "@/registry/paragon/ui/digit-roll";
const EASE_OUT: [number, number, number, number] = [0.22, 1, 0.36, 1];
/** Fixed burst geometry — deterministic, no randomness at render. */
const PARTICLES = Array.from({ length: 6 }, (_, i) => {
const angle = ((i * 60 - 90) * Math.PI) / 180;
const distance = 18 + (i % 2) * 7;
return {
x: Math.cos(angle) * distance,
y: Math.sin(angle) * distance,
delay: (i % 3) * 0.02,
};
});
export interface ReactionButtonProps
extends Omit<React.ComponentProps<"button">, "children"> {
/** The reaction glyph. */
emoji?: string;
/** Accessible name for the reaction, e.g. "Thumbs up". */
label?: string;
/** Count excluding the current user; +1 is applied optimistically. */
count?: number;
/** Controlled pressed state. Leave undefined for uncontrolled. */
pressed?: boolean;
defaultPressed?: boolean;
onPressedChange?: (pressed: boolean) => void;
/** Particle burst on activation. */
burst?: boolean;
/** Disables pop, burst, and digit roll; state still changes. */
static?: boolean;
}
/**
* A reaction pill: press to react, press again to take it back. Activation
* pops the emoji, pulses a ring, fires a six-dot burst on fixed geometry
* (deterministic — no render-time randomness), and rolls the count up
* optimistically. The burst is a rare-moment delight, so it only fires on
* the off-to-on edge; un-reacting is silent per the house frequency rule.
*/
export function ReactionButton({
emoji = "👍",
label = "Thumbs up",
count = 0,
pressed: pressedProp,
defaultPressed = false,
onPressedChange,
burst = true,
static: isStatic = false,
className,
onClick,
disabled,
...props
}: ReactionButtonProps) {
const reduced = useReducedMotion() ?? false;
const [uncontrolled, setUncontrolled] = React.useState(defaultPressed);
const isControlled = pressedProp !== undefined;
const isPressed = isControlled ? pressedProp : uncontrolled;
const [burstId, setBurstId] = React.useState(0);
const burstTimer = React.useRef<ReturnType<typeof setTimeout>>(null);
React.useEffect(() => {
return () => {
if (burstTimer.current) clearTimeout(burstTimer.current);
};
}, []);
const celebrate = !isStatic && !reduced;
const total = count + (isPressed ? 1 : 0);
return (
<button
type="button"
data-slot="reaction-button"
aria-pressed={isPressed}
aria-label={`${label}, ${total} ${total === 1 ? "reaction" : "reactions"}`}
disabled={disabled}
onClick={(event) => {
onClick?.(event);
if (event.defaultPrevented) return;
const next = !isPressed;
if (!isControlled) setUncontrolled(next);
onPressedChange?.(next);
if (next && burst && celebrate) {
setBurstId((id) => id + 1);
if (burstTimer.current) clearTimeout(burstTimer.current);
burstTimer.current = setTimeout(() => setBurstId(0), 600);
}
}}
className={cn(
"group/reaction relative inline-flex h-8 shrink-0 items-center gap-1.5 rounded-full px-2.5 text-[13px] font-medium select-none",
"transition-[background-color,box-shadow,color,scale] duration-150 ease-out",
"outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
"disabled:pointer-events-none disabled:opacity-50",
!isStatic && "active:not-disabled:scale-[0.95]",
isPressed
? "bg-primary/10 text-foreground ring-1 ring-primary/25 ring-inset"
: "bg-card text-muted-foreground shadow-border hover:text-foreground hover:shadow-border-hover",
"after:absolute after:inset-x-0 after:top-1/2 after:h-10 after:-translate-y-1/2 after:rounded-full",
className,
)}
{...props}
>
<span className="relative flex items-center justify-center">
<motion.span
aria-hidden
className="text-sm leading-none"
animate={
celebrate && isPressed
? { scale: [null, 1.35, 1] }
: { scale: 1 }
}
transition={{ duration: 0.35, ease: EASE_OUT, times: [0, 0.4, 1] }}
>
{emoji}
</motion.span>
{burstId > 0 && (
<React.Fragment key={burstId}>
{/* Ring pulse — ends at opacity 0, removed when the timer clears. */}
<motion.span
aria-hidden
className="pointer-events-none absolute inset-[-3px] rounded-full border border-primary/40"
initial={{ scale: 0.6, opacity: 1 }}
animate={{ scale: 1.7, opacity: 0 }}
transition={{ duration: 0.45, ease: EASE_OUT }}
/>
{/* Dot burst on fixed geometry */}
{PARTICLES.map((particle, i) => (
<motion.span
key={i}
aria-hidden
className="pointer-events-none absolute size-1 rounded-full bg-primary/60"
initial={{ x: 0, y: 0, scale: 0.4, opacity: 1 }}
animate={{
x: particle.x,
y: particle.y,
scale: 1,
opacity: 0,
}}
transition={{
duration: 0.45,
ease: EASE_OUT,
delay: particle.delay,
}}
/>
))}
</React.Fragment>
)}
</span>
<DigitRoll
aria-hidden
value={total}
static={isStatic}
className="min-w-[1ch]"
/>
</button>
);
}