A restrained emoji reaction bar: toggleable pills whose counts roll by a single digit, with a compact add-emoji palette.
npx shadcn@latest add @paragon/reaction-pickerAlso installs: tooltip
"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>
);
}