SaaS & Ops

Reaction Picker

A restrained emoji reaction bar: toggleable pills whose counts roll by a single digit, with a compact add-emoji palette.

Install

npx shadcn@latest add @paragon/reaction-picker

Also installs: tooltip

reaction-picker.tsx

"use client";

import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { SmilePlus } from "lucide-react";
import {
  Tooltip,
  TooltipContent,
  TooltipProvider,
  TooltipTrigger,
} from "@/registry/paragon/ui/tooltip";
import { cn } from "@/lib/utils";

export interface Reaction {
  emoji: string;
  count: number;
  reacted?: boolean;
}

export interface ReactionPickerProps
  extends Omit<React.ComponentProps<"div">, "onChange"> {
  reactions?: Reaction[];
  /** Emoji offered by the "+" picker. */
  palette?: string[];
  onChange?: (reactions: Reaction[]) => void;
}

const DEFAULT_REACTIONS: Reaction[] = [
  { emoji: "πŸ‘", count: 8, reacted: true },
  { emoji: "πŸŽ‰", count: 3 },
  { emoji: "πŸš€", count: 2 },
];

const DEFAULT_PALETTE = ["πŸ‘", "❀️", "πŸŽ‰", "πŸš€", "πŸ‘€", "πŸ™", "πŸ”₯", "βœ…"];

/** One rolling digit β€” exits up, new digit rises in. Restrained, not bouncy. */
function CountRoll({ value, reduced }: { value: number; reduced: boolean }) {
  return (
    <span className="relative inline-flex h-4 items-center overflow-hidden tabular-nums">
      <AnimatePresence mode="popLayout" initial={false}>
        <motion.span
          key={value}
          initial={reduced ? { opacity: 0 } : { y: "70%", opacity: 0 }}
          animate={reduced ? { opacity: 1 } : { y: 0, opacity: 1 }}
          exit={reduced ? { opacity: 0 } : { y: "-70%", opacity: 0 }}
          transition={{ type: "spring", duration: 0.3, bounce: 0 }}
          className="inline-block"
        >
          {value}
        </motion.span>
      </AnimatePresence>
    </span>
  );
}

/**
 * An emoji reaction bar. Each pill toggles the viewer's vote; on toggle the
 * count rolls by one digit (no confetti β€” the pop is one subtle scale beat),
 * and a fresh reaction springs in. The "+" opens a compact palette. Newly
 * added emoji animate in; a reaction dropping to zero exits. Reduced motion
 * swaps every roll and pop for a plain crossfade.
 */
export function ReactionPicker({
  reactions = DEFAULT_REACTIONS,
  palette = DEFAULT_PALETTE,
  onChange,
  className,
  ...props
}: ReactionPickerProps) {
  const reduced = useReducedMotion();
  const [items, setItems] = React.useState(reactions);
  const [open, setOpen] = React.useState(false);

  const commit = (next: Reaction[]) => {
    setItems(next);
    onChange?.(next);
  };

  const toggle = (emoji: string) => {
    const index = items.findIndex((r) => r.emoji === emoji);
    if (index === -1) {
      commit([...items, { emoji, count: 1, reacted: true }]);
      return;
    }
    const r = items[index];
    const reacted = !r.reacted;
    const count = r.count + (reacted ? 1 : -1);
    const next = [...items];
    if (count <= 0) next.splice(index, 1);
    else next[index] = { ...r, reacted, count };
    commit(next);
  };

  return (
    <TooltipProvider>
    <div
      data-slot="reaction-picker"
      className={cn("flex flex-wrap items-center gap-1.5", className)}
      {...props}
    >
      <AnimatePresence initial={false} mode="popLayout">
        {items.map((reaction) => (
          <motion.button
            key={reaction.emoji}
            type="button"
            layout={!reduced}
            onClick={() => toggle(reaction.emoji)}
            aria-pressed={reaction.reacted}
            aria-label={`${reaction.emoji} reaction, ${reaction.count}`}
            initial={reduced ? { opacity: 0 } : { opacity: 0, scale: 0.9 }}
            animate={reduced ? { opacity: 1 } : { opacity: 1, scale: 1 }}
            exit={reduced ? { opacity: 0 } : { opacity: 0, scale: 0.9 }}
            whileTap={reduced ? undefined : { scale: 0.94 }}
            transition={{ type: "spring", duration: 0.3, bounce: 0 }}
            className={cn(
              "inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs outline-none",
              "transition-colors duration-(--duration-fast)",
              "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
              reaction.reacted
                ? "border-primary/40 bg-primary/10 text-foreground"
                : "border-border bg-secondary/50 text-muted-foreground hover:text-foreground",
            )}
          >
            <span aria-hidden className="text-sm leading-none">
              {reaction.emoji}
            </span>
            <CountRoll value={reaction.count} reduced={!!reduced} />
          </motion.button>
        ))}
      </AnimatePresence>

      <div className="relative">
        <Tooltip>
          <TooltipTrigger asChild>
            <button
              type="button"
              aria-label="Add reaction"
              aria-expanded={open}
              onClick={() => setOpen((v) => !v)}
              onBlur={() => setOpen(false)}
              className={cn(
                "pressable inline-flex size-6 items-center justify-center rounded-full border border-border text-muted-foreground outline-none",
                "transition-colors duration-(--duration-fast) hover:text-foreground hover:border-border/80",
                "focus-visible:ring-2 focus-visible:ring-ring",
              )}
            >
              <SmilePlus className="size-3.5" aria-hidden />
            </button>
          </TooltipTrigger>
          <TooltipContent>Add reaction</TooltipContent>
        </Tooltip>
        <AnimatePresence>
          {open && (
            <motion.div
              role="menu"
              aria-label="Pick a reaction"
              initial={reduced ? { opacity: 0 } : { opacity: 0, y: 6, scale: 0.96, filter: "blur(4px)" }}
              animate={reduced ? { opacity: 1 } : { opacity: 1, y: 0, scale: 1, filter: "blur(0px)" }}
              exit={reduced ? { opacity: 0 } : { opacity: 0, y: 4, scale: 0.98, filter: "blur(4px)" }}
              transition={{ duration: 0.15, ease: [0.22, 1, 0.36, 1] }}
              style={{ transformOrigin: "bottom left" }}
              className="absolute bottom-full left-0 z-10 mb-1.5 flex gap-0.5 rounded-lg bg-popover p-1 shadow-overlay"
            >
              {palette.map((emoji) => (
                <button
                  key={emoji}
                  type="button"
                  role="menuitem"
                  onMouseDown={(event) => {
                    event.preventDefault();
                    toggle(emoji);
                    setOpen(false);
                  }}
                  aria-label={`React ${emoji}`}
                  className={cn(
                    "flex size-7 items-center justify-center rounded-md text-base outline-none",
                    "transition-[scale,background-color] duration-(--duration-fast) ease-out",
                    "hover:bg-accent active:scale-[0.9] focus-visible:ring-2 focus-visible:ring-ring",
                  )}
                >
                  {emoji}
                </button>
              ))}
            </motion.div>
          )}
        </AnimatePresence>
      </div>
    </div>
    </TooltipProvider>
  );
}