Reaction Button
Buttons

Reaction Button

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.

Install

npx shadcn@latest add @paragon/reaction-button

Also installs: digit-roll

reaction-button.tsx

"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>
  );
}