Emoji reactions
- Source
- After bencho.dev ↗
- Author
- Interaction after bencho.dev
- Licence
- Original code — interaction after bencho.dev
Loading…
Tap a reaction to add yours, or open the tray to pick a new one.
Code
"use client";
import { AnimatePresence, motion } from "motion/react";
import { SmilePlus } from "lucide-react";
import { useState } from "react";
import { Surface } from "../ui";
const TRAY = ["🚀", "🔥", "👏", "😍", "🎉", "👀"];
type Reaction = { emoji: string; count: number; mine: boolean };
type Float = { id: number; emoji: string; x: number; drift: number };
function Count({ value }: { value: number }) {
return (
<span className="relative inline-grid h-[1.3em] overflow-hidden tabular-nums">
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={value}
initial={{ y: "-100%", opacity: 0 }}
animate={{ y: "0%", opacity: 1 }}
exit={{ y: "100%", opacity: 0 }}
transition={{ type: "spring", stiffness: 400, damping: 30 }}
>
{value}
</motion.span>
</AnimatePresence>
</span>
);
}
export default function EmojiReactions() {
const [items, setItems] = useState<Reaction[]>([
{ emoji: "🚀", count: 3, mine: false },
{ emoji: "🔥", count: 2, mine: false },
]);
const [tray, setTray] = useState(false);
const [floats, setFloats] = useState<Float[]>([]);
const [fid, setFid] = useState(0);
const burst = (emoji: string, x: number) => {
const made = [0, 1, 2].map((k) => ({ id: fid + k, emoji, x, drift: (k - 1) * 1.4 }));
setFid((n) => n + 3);
setFloats((f) => [...f, ...made]);
};
const toggle = (emoji: string, el?: HTMLElement) => {
const x = el ? el.offsetLeft + el.offsetWidth / 2 : 40;
setItems((list) => {
const hit = list.find((r) => r.emoji === emoji);
if (!hit) return [...list, { emoji, count: 1, mine: true }];
return list
.map((r) => (r.emoji === emoji ? { ...r, mine: !r.mine, count: r.count + (r.mine ? -1 : 1) } : r))
.filter((r) => r.count > 0);
});
const hit = items.find((r) => r.emoji === emoji);
if (!hit || !hit.mine) burst(emoji, x);
};
return (
<Surface>
<div className="relative w-[19em]">
<div className="mb-[0.6em] w-fit rounded-[1.1em] bg-white px-[1.1em] py-[0.7em] text-[0.95em] shadow-[0_6px_20px_-14px_rgba(0,0,0,0.3)]">
Shipped the new onboarding flow
</div>
<motion.div layout className="relative flex flex-wrap items-center gap-[0.45em]">
<AnimatePresence initial={false}>
{items.map((r) => (
<motion.button
layout
key={r.emoji}
type="button"
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0, opacity: 0 }}
whileTap={{ scale: 0.88 }}
onClick={(e) => toggle(r.emoji, e.currentTarget)}
className={`flex h-[2.3em] items-center gap-[0.45em] rounded-full px-[0.8em] text-[0.9em] transition-colors ${r.mine ? "bg-[#e8ecfb] text-[#2B46B8] shadow-[inset_0_0_0_1.5px_#2B46B8]" : "bg-white shadow-[0_2px_8px_-4px_rgba(0,0,0,0.2)]"}`}
>
<motion.span key={`${r.emoji}-${r.mine}`} initial={{ scale: 0.6, rotate: -20 }} animate={{ scale: 1, rotate: 0 }} transition={{ type: "spring", stiffness: 500, damping: 12 }}>
{r.emoji}
</motion.span>
<Count value={r.count} />
</motion.button>
))}
</AnimatePresence>
<motion.button
layout
type="button"
aria-label="Add reaction"
onClick={() => setTray((t) => !t)}
whileTap={{ scale: 0.88 }}
className={`grid h-[2.3em] w-[2.3em] place-items-center rounded-full ${tray ? "bg-[#151515] text-white" : "bg-white text-[#8a8984]"} shadow-[0_2px_8px_-4px_rgba(0,0,0,0.2)]`}
>
<SmilePlus className="h-[1.1em] w-[1.1em]" />
</motion.button>
{floats.map((f) => (
<motion.span
key={f.id}
className="pointer-events-none absolute top-0 text-[1.3em]"
style={{ left: f.x }}
initial={{ y: 0, x: "-50%", opacity: 1, scale: 0.6 }}
animate={{ y: "-3.5em", x: `calc(-50% + ${f.drift}em)`, opacity: 0, scale: 1.2 }}
transition={{ duration: 0.9, ease: "easeOut", delay: Math.abs(f.drift) * 0.05 }}
onAnimationComplete={() => setFloats((all) => all.filter((x) => x.id !== f.id))}
>
{f.emoji}
</motion.span>
))}
</motion.div>
<AnimatePresence>
{tray && (
<motion.div
initial={{ opacity: 0, y: 8, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 8, scale: 0.95 }}
className="absolute left-0 top-full mt-[0.6em] flex gap-[0.2em] rounded-full bg-white p-[0.3em] shadow-[0_10px_30px_-12px_rgba(0,0,0,0.3)]"
>
{TRAY.map((e, i) => (
<motion.button
key={e}
type="button"
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0, transition: { delay: i * 0.03 } }}
whileHover={{ scale: 1.35, y: -4 }}
whileTap={{ scale: 0.9 }}
onClick={() => {
toggle(e);
setTray(false);
}}
className="grid h-[2em] w-[2em] place-items-center rounded-full text-[1.1em] hover:bg-[#f1f0ec]"
>
{e}
</motion.button>
))}
</motion.div>
)}
</AnimatePresence>
</div>
</Surface>
);
}
Prompt
Build "Emoji reactions", a button animation. What it does: Tap a reaction to add yours, or open the tray to pick a new one. Write it as a single self-contained React component in TypeScript that fills its parent (w-full h-full), styled with Tailwind CSS, using lucide-react, motion. Technique: an HTML canvas, the Motion library, pointer tracking. It sits on a light background, in a 16:10 frame. Respect prefers-reduced-motion with a still state, pause work when the element is off screen, and keep it accessible. Original: https://bencho.dev (Interaction after bencho.dev, Original code — interaction after bencho.dev).