Goo Reaction Bar
A like pill that tears apart into a fan of emoji reactions. Pill and blobs share one goo-filtered metaball layer, so each reaction stretches a liquid neck before it separates, while the glyphs ride an unfiltered layer and stay crisp. Staggered fan-out, reversed fan-in, hover labels, and an optimistic count.
Micro InteractionReactTailwind CSSSVG Filters
CSSshadcn
Manual
Create a file and paste the following code into it.
src/components/ui/reaction-bar.tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
"use client";
import { useEffect, useId, useRef, useState } from "react";
import { Heart } from "@phosphor-icons/react";
import { cn } from "@/lib/cn";
interface Reaction {
id: string;
emoji: string;
label: string;
count: number;
/** Slot on the fan, in px from the trigger's centre. */
x: number;
y: number;
}
/*
* Slots sit on a shallow arc above the pill. Centres are 56px apart while the
* blobs are 44px across: any tighter and the goo threshold fuses neighbours
* into one amoeba that never separates. The 12px gap is what lets a neck
* stretch and snap.
*/
const REACTIONS: Reaction[] = [
{ id: "love", emoji: "❤️", label: "Love", count: 128, x: -112, y: -58 },
{ id: "haha", emoji: "😂", label: "Haha", count: 41, x: -56, y: -70 },
{ id: "party", emoji: "🎉", label: "Celebrate", count: 63, x: 0, y: -76 },
{ id: "wow", emoji: "😮", label: "Wow", count: 17, x: 56, y: -70 },
{ id: "clap", emoji: "👏", label: "Applaud", count: 22, x: 112, y: -58 },
];
const BASE_TOTAL = REACTIONS.reduce((sum, r) => sum + r.count, 0);
/*
* Travel is CSS, not Motion, for two reasons: the shape layer and the emoji
* layer must move as one (any drift and the goo detaches from its glyph), and
* an entrance gated on the async useReducedMotion() value can latch at
* opacity 0 before that value ever resolves.
*/
const REACTION_STYLES = `
.grb-slot {
transform: translate(0px, 0px) scale(0.34);
transition:
transform 460ms cubic-bezier(0.34, 1.4, 0.64, 1),
opacity 160ms linear;
transition-delay: calc(var(--ri) * 45ms);
}
[data-open="true"] .grb-slot {
transform: translate(var(--x), var(--y)) scale(1);
transition-delay: calc(var(--i) * 45ms);
}
.grb-emoji { opacity: 0; }
[data-open="true"] .grb-emoji { opacity: 1; }
.grb-label {
opacity: 0;
transition: opacity 140ms linear;
}
.grb-emoji:hover .grb-label,
.grb-emoji:focus-visible .grb-label {
opacity: 1;
}
@keyframes grb-count-pop {
0% { transform: translateY(0) scale(1); }
40% { transform: translateY(-2px) scale(1.14); }
100% { transform: translateY(0) scale(1); }
}
.grb-count[data-bumped="true"] {
animation: grb-count-pop 380ms cubic-bezier(0.22, 1, 0.36, 1);
}
@media (prefers-reduced-motion: reduce) {
.grb-slot {
transition: none;
transform: translate(var(--x), var(--y)) scale(1);
opacity: 0;
}
[data-open="true"] .grb-slot { opacity: 1; }
.grb-label { transition: none; }
.grb-count[data-bumped="true"] { animation: none; }
}
`;
/**
* A like button that splits into a fan of emoji reactions. The trigger pill and
* every reaction blob live in one goo-filtered layer, so the blobs tear away
* from the pill through stretching liquid necks rather than simply appearing.
*
* The glyphs ride an unfiltered layer pinned to the same transforms — the goo
* blurs the shapes, never the emoji.
*
* @param {string} className - Extra classes on the root. [Optional]
*
* @example
* <ReactionBar />
*/
export function ReactionBar({ className }: { className?: string }) {
const gooId = useId().replace(/:/g, "");
const rootRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const [isOpen, setIsOpen] = useState(false);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [isBumped, setIsBumped] = useState(false);
const [liveMessage, setLiveMessage] = useState("");
const selected = REACTIONS.find((r) => r.id === selectedId) ?? null;
const total = BASE_TOTAL + (selected ? 1 : 0);
useEffect(() => {
if (!isOpen) return;
const onKey = (event: KeyboardEvent) => {
if (event.key !== "Escape") return;
setIsOpen(false);
triggerRef.current?.focus();
};
const onPointer = (event: PointerEvent) => {
if (rootRef.current && !rootRef.current.contains(event.target as Node)) setIsOpen(false);
};
window.addEventListener("keydown", onKey);
window.addEventListener("pointerdown", onPointer);
return () => {
window.removeEventListener("keydown", onKey);
window.removeEventListener("pointerdown", onPointer);
};
}, [isOpen]);
const choose = (reaction: Reaction) => {
const isSame = reaction.id === selectedId;
setSelectedId(isSame ? null : reaction.id);
setIsBumped(!isSame);
setLiveMessage(isSame ? "Reaction removed" : `Reacted with ${reaction.label}`);
setIsOpen(false);
// Closing marks the glyph layer inert. A keyboard user standing on the
// chosen emoji would be dropped onto <body>; hand focus back to the pill.
triggerRef.current?.focus();
};
const slotVars = (index: number, reaction: Reaction) =>
({
"--i": index,
"--ri": REACTIONS.length - 1 - index,
"--x": `${reaction.x}px`,
"--y": `${reaction.y}px`,
}) as React.CSSProperties;
return (
<div ref={rootRef} className={cn("relative isolate inline-flex", className)}>
{/* href+precedence let React 19 hoist and dedupe this stylesheet across
reaction bar instances. */}
<style href="goo-reaction-bar" precedence="medium">
{REACTION_STYLES}
</style>
{/* Goo filter: blur, snap the alpha back into solid shapes, then lay the
crisp original on top so neighbouring blobs fuse at liquid edges. */}
<svg aria-hidden width="0" height="0" className="pointer-events-none absolute">
<title>Reaction goo filter</title>
<defs>
<filter id={gooId} x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur in="SourceGraphic" stdDeviation="7" result="blur" />
<feColorMatrix
in="blur"
mode="matrix"
values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 20 -9"
result="goo"
/>
<feComposite in="SourceGraphic" in2="goo" operator="atop" />
</filter>
</defs>
</svg>
{/* Shape layer — the metaball body. Filter is dropped by CSS, not by the
reduce flag: useReducedMotion() reads false on the server and true on
the client, which would desync hydration. */}
<div
aria-hidden
data-open={isOpen}
className="pointer-events-none absolute left-1/2 top-1/2 -z-10 h-[280px] w-[360px] -translate-x-1/2 -translate-y-1/2 [filter:var(--goo-filter)] motion-reduce:[filter:none]"
style={{ "--goo-filter": `url(#${gooId})` } as React.CSSProperties}
>
<div className="absolute left-1/2 top-1/2 -ml-14 -mt-[22px] h-11 w-28 rounded-full bg-popover" />
{REACTIONS.map((reaction, index) => (
<div
key={reaction.id}
style={slotVars(index, reaction)}
className="grb-slot absolute left-1/2 top-1/2 -ml-[22px] -mt-[22px] size-11 rounded-full bg-popover"
/>
))}
</div>
{/* The pill's own face. The white surface behind it comes from the goo. */}
<button
ref={triggerRef}
type="button"
onClick={() => setIsOpen((open) => !open)}
aria-expanded={isOpen}
aria-label={selected ? `Reacted with ${selected.label}` : "Add a reaction"}
className="relative z-10 flex h-11 w-28 items-center justify-center gap-2 rounded-full text-neutral-900 outline-none transition-transform duration-150 focus-visible:ring-2 focus-visible:ring-white/70 active:scale-[0.97] motion-reduce:transition-none"
>
{selected ? (
<span className="text-lg leading-none">{selected.emoji}</span>
) : (
<Heart size={18} weight="regular" className="text-neutral-500" />
)}
<span
className="grb-count text-sm font-semibold tabular-nums"
data-bumped={isBumped}
onAnimationEnd={() => setIsBumped(false)}
>
{total}
</span>
</button>
{/* Glyph layer — same transforms, no filter, so the emoji stay crisp.
The container never takes pointer events (it spans 360×280 over the
pill and the post text — as a hit target it would swallow their
clicks); only the emoji buttons re-enable them, and inert keeps those
untouchable while closed. */}
<div
inert={!isOpen}
data-open={isOpen}
role="group"
aria-label="Reactions"
className="pointer-events-none absolute left-1/2 top-1/2 z-10 h-[280px] w-[360px] -translate-x-1/2 -translate-y-1/2"
>
{REACTIONS.map((reaction, index) => (
<button
key={reaction.id}
type="button"
onClick={() => choose(reaction)}
aria-pressed={reaction.id === selectedId}
aria-label={`${reaction.label}, ${
reaction.count + (reaction.id === selectedId ? 1 : 0)
} reactions`}
style={slotVars(index, reaction)}
className={cn(
"grb-slot grb-emoji pointer-events-auto absolute left-1/2 top-1/2 -ml-[22px] -mt-[22px] grid size-11 place-items-center rounded-full text-xl outline-none",
"focus-visible:ring-2 focus-visible:ring-[#f97316]",
reaction.id === selectedId && "ring-2 ring-[#f97316]",
)}
>
<span aria-hidden>{reaction.emoji}</span>
<span className="grb-label pointer-events-none absolute -top-8 whitespace-nowrap rounded-md bg-neutral-900 px-2 py-1 text-[11px] font-medium text-white ring-1 ring-white/10">
{reaction.label}
</span>
</button>
))}
</div>
<span aria-live="polite" className="sr-only">
{liveMessage}
</span>
</div>
);
}
Update the import paths to match your project setup.
Similar components
Resource details
PublishedJuly 10, 2026
CategoryMicro Interaction
ReactTailwind CSSSVG Filters
Resource details
PublishedJuly 10, 2026
CategoryMicro Interaction
ReactTailwind CSSSVG Filters