Message Reaction Attach
The chosen glyph flies out of the picker and docks at the corner of the bubble it belongs to.
The animated component in this preview is rendered from the canonical file shown here. The surrounding demo shell only provides context and is not part of the copied code.
import { useId, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
/**
* Vibary · Message Reaction Attach
*
* Pick a reaction and it flies out of the picker and docks at the corner
* of the bubble it belongs to — one continuous object, so the reaction is
* visibly attached to that message rather than merely appearing near it.
*
* Self-contained: depends only on `motion` (react ships with your app).
* Glyphs are inline SVG, never emoji, so they inherit the type color and
* stay crisp at any size. Surfaces are mixed from the inherited text
* color, so the thread reads correctly on light and dark pages.
* Works with zero props; tune via `variant`, `message`, `author`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type MessageReactionAttachProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
message?: string;
author?: string;
initials?: string;
timestamp?: string;
/** Called with the reaction key, or null when yours is taken back. */
onReactionChange?: (key: string | null) => void;
/** Reaction color. A state color, so it stays literal. */
accent?: string;
};
type VariantConfig = {
/** Seconds between picker glyphs arriving. */
stagger: number;
/** px each picker glyph rises from. */
rise: number;
/** Carries the glyph from the picker to the dock. */
spring: { type: "spring"; stiffness: number; damping: number };
};
// Quality rule: the flight lands, it does not bounce. Every spring here
// is at or above a 0.8 damping ratio (damping / 2√stiffness) — a
// reaction that wobbles onto a bubble reads as a sticker being dropped,
// not as a mark being attached. Variants differ in speed and travel.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// Quick and quiet. For a busy channel where reactions are constant.
subtle: {
stagger: 0.017,
rise: 3,
spring: { type: "spring", stiffness: 650, damping: 47 },
},
// Enough travel to follow the glyph across. All-purpose.
default: {
stagger: 0.035,
rise: 7,
spring: { type: "spring", stiffness: 500, damping: 40 },
},
// A longer arc and a wider fan, for a one-to-one conversation.
playful: {
stagger: 0.053,
rise: 11,
spring: { type: "spring", stiffness: 380, damping: 33 },
},
};
const ACCENT = "#E0577F";
/** Theme-adaptive neutral: mixing the inherited text color with
* `transparent` yields surfaces and borders correctly toned on a light
* page and on a dark one. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
type Reaction = { key: string; label: string; paths: string[] };
// Inline SVG, drawn on a 24×24 grid. Emoji would inherit the reader's
// font and platform; these do not.
const REACTIONS: readonly Reaction[] = [
{
key: "heart",
label: "Heart",
paths: ["M12 20s-7.6-4.7-7.6-9.7A4.4 4.4 0 0 1 12 7.5a4.4 4.4 0 0 1 7.6 2.8C19.6 15.3 12 20 12 20Z"],
},
{
key: "up",
label: "Thumbs up",
paths: [
"M8.4 20.4V10.6l3.8-6.4a1.5 1.5 0 0 1 2.8 1.1l-1 4.5h4.4a1.9 1.9 0 0 1 1.9 2.3l-1.3 6.4a1.9 1.9 0 0 1-1.9 1.5Z",
"M3.6 10.6h4.8v9.8H3.6Z",
],
},
{
key: "star",
label: "Star",
paths: ["m12 4.3 2.4 4.9 5.4.8-3.9 3.8.9 5.4-4.8-2.6-4.8 2.6.9-5.4-3.9-3.8 5.4-.8Z"],
},
{
key: "smile",
label: "Smile",
paths: [
"M12 3.9a8.1 8.1 0 1 1 0 16.2 8.1 8.1 0 0 1 0-16.2Z",
"M8.3 13.3a4.4 4.4 0 0 0 7.4 0",
"M9.4 9.7v.7",
"M14.6 9.7v.7",
],
},
];
/** Reactions other people already left. */
const BASE_COUNTS: Record<string, number> = { heart: 0, up: 3, star: 0, smile: 0 };
function Glyph({
reaction,
size,
active,
accent,
}: {
reaction: Reaction;
size: number;
active: boolean;
accent: string;
}) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
aria-hidden
style={{ display: "block", overflow: "visible" }}
>
{reaction.paths.map((d) => (
<path
key={d}
d={d}
fill={
active && d.endsWith("Z")
? `color-mix(in srgb, ${accent} 22%, transparent)`
: "none"
}
stroke={active ? accent : "currentColor"}
strokeWidth={1.7}
strokeLinecap="round"
strokeLinejoin="round"
opacity={active ? 1 : 0.66}
/>
))}
</svg>
);
}
export default function MessageReactionAttach({
variant = "default",
message = "Pushed the revised timeline — the launch review moves to Thursday.",
author = "Dana Reyes",
initials = "DR",
timestamp = "9:41",
onReactionChange,
accent = ACCENT,
}: MessageReactionAttachProps) {
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
const uid = useId();
const [open, setOpen] = useState(false);
const [mine, setMine] = useState<string | null>(null);
const choose = (key: string) => {
const next = mine === key ? null : key;
setMine(next);
setOpen(false);
onReactionChange?.(next);
};
const pills = REACTIONS.map((reaction) => ({
reaction,
count: BASE_COUNTS[reaction.key] + (mine === reaction.key ? 1 : 0),
isMine: mine === reaction.key,
})).filter((pill) => pill.count > 0);
// Reduced motion: the glyph is not flown across, it is simply in the
// dock on the next frame. Which reaction landed, and on which message,
// is carried by position and color rather than by the trip.
const flight = reduceMotion ? { duration: 0 } : cfg.spring;
return (
<div style={{ width: 320, paddingTop: 52 }}>
<div style={{ display: "flex", alignItems: "flex-end", gap: 9 }}>
<span
aria-hidden
style={{
width: 28,
height: 28,
flexShrink: 0,
borderRadius: "50%",
display: "grid",
placeItems: "center",
fontSize: 11,
fontWeight: 600,
color: "#fff",
background: "linear-gradient(140deg, #4C7DF0, #7C5AE8)",
}}
>
{initials}
</span>
<div style={{ position: "relative", minWidth: 0 }}>
{/* The picker floats above the bubble it acts on, which is what
makes the flight downward read as docking. */}
<AnimatePresence>
{open && (
<motion.div
key="picker"
initial={{ opacity: 0, y: reduceMotion ? 0 : 6 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: reduceMotion ? 0 : 4 }}
transition={{ duration: 0.16, ease: "easeOut" }}
style={{
position: "absolute",
bottom: "calc(100% + 10px)",
left: 0,
zIndex: 2,
display: "flex",
gap: 2,
padding: 5,
borderRadius: 999,
background: "Canvas",
color: "CanvasText",
border: `1px solid ${tone(12)}`,
boxShadow: "0 12px 28px rgba(0,0,0,0.18)",
}}
>
{REACTIONS.map((reaction, index) => (
<motion.button
key={reaction.key}
type="button"
onClick={() => choose(reaction.key)}
aria-label={reaction.label}
initial={{ opacity: 0, y: reduceMotion ? 0 : cfg.rise }}
animate={{ opacity: 1, y: 0 }}
transition={{
...flight,
delay: reduceMotion ? 0 : index * cfg.stagger,
}}
whileHover={reduceMotion ? undefined : { y: -2 }}
style={{
display: "grid",
placeItems: "center",
width: 30,
height: 30,
padding: 0,
borderRadius: "50%",
border: 0,
background: "transparent",
color: "inherit",
cursor: "pointer",
}}
>
{/* Shared identity between picker and dock: Motion
carries this glyph across instead of fading one out
and another in. */}
<motion.span
layoutId={reduceMotion ? undefined : `${uid}-${reaction.key}`}
transition={{ layout: flight }}
style={{ display: "block" }}
>
<Glyph
reaction={reaction}
size={19}
active={mine === reaction.key}
accent={accent}
/>
</motion.span>
</motion.button>
))}
</motion.div>
)}
</AnimatePresence>
<div
style={{
maxWidth: 244,
padding: "10px 13px 11px",
borderRadius: 16,
borderBottomLeftRadius: 6,
background: tone(8),
fontSize: 13,
lineHeight: 1.45,
}}
>
{message}
<div style={{ fontSize: 10.5, opacity: 0.45, marginTop: 5 }}>
{author} · {timestamp}
</div>
</div>
{/* Docked reactions overlap the bubble's bottom edge: they belong
to the message, so they sit on it rather than beneath it. */}
<div
style={{
position: "absolute",
left: 12,
bottom: -13,
display: "flex",
gap: 5,
zIndex: 1,
}}
>
<AnimatePresence initial={false}>
{pills.map((pill) => (
<motion.button
key={pill.reaction.key}
type="button"
layout={reduceMotion ? false : "position"}
onClick={() => choose(pill.reaction.key)}
aria-pressed={pill.isMine}
aria-label={`${pill.reaction.label}, ${pill.count}`}
initial={{ opacity: 0, y: reduceMotion ? 0 : -4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: reduceMotion ? 0 : -3 }}
transition={{
opacity: { duration: 0.18, ease: "easeOut" },
y: flight,
layout: flight,
}}
style={{
display: "flex",
alignItems: "center",
gap: 4,
padding: "3px 8px 3px 6px",
borderRadius: 999,
border: `1px solid ${pill.isMine ? `color-mix(in srgb, ${accent} 40%, transparent)` : tone(10)}`,
background: pill.isMine
? `color-mix(in srgb, ${accent} 14%, transparent)`
: "Canvas",
color: "CanvasText",
fontFamily: "inherit",
cursor: "pointer",
}}
>
<motion.span
layoutId={
reduceMotion ? undefined : `${uid}-${pill.reaction.key}`
}
transition={{ layout: flight }}
style={{ display: "block" }}
>
<Glyph
reaction={pill.reaction}
size={14}
active={pill.isMine}
accent={accent}
/>
</motion.span>
{/* Fixed slot: the number swaps in place, at one size,
so a count going from 3 to 4 never nudges the pill. */}
<span
style={{
position: "relative",
width: 8,
height: 13,
fontSize: 11,
fontWeight: 600,
fontVariantNumeric: "tabular-nums",
}}
>
<AnimatePresence initial={false}>
<motion.span
key={pill.count}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.14, ease: "easeOut" }}
style={{
position: "absolute",
inset: 0,
lineHeight: "13px",
textAlign: "center",
color: pill.isMine ? accent : "inherit",
opacity: pill.isMine ? 1 : 0.6,
}}
>
{pill.count}
</motion.span>
</AnimatePresence>
</span>
</motion.button>
))}
</AnimatePresence>
</div>
</div>
<button
type="button"
onClick={() => setOpen((value) => !value)}
aria-expanded={open}
aria-label="Add a reaction"
style={{
display: "grid",
placeItems: "center",
flexShrink: 0,
width: 26,
height: 26,
padding: 0,
borderRadius: "50%",
border: `1px solid ${tone(12)}`,
background: open ? tone(10) : tone(4),
color: "inherit",
cursor: "pointer",
}}
>
<motion.span
aria-hidden
initial={false}
animate={{ rotate: open ? 45 : 0 }}
transition={reduceMotion ? { duration: 0 } : cfg.spring}
style={{ display: "block", opacity: 0.6 }}
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none">
<path
d="M12 5.5v13M5.5 12h13"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
/>
</svg>
</motion.span>
</button>
</div>
</div>
);
}About this pattern
A reaction is a mark on one specific message, so the motion has to say which one. The glyph the reader picks keeps its identity across the two places it lives — picker and dock — and is carried between them as a single object, which is a claim of ownership no fade could make: the mark that was in your hand is now on that bubble. It lands over the bubble's bottom edge rather than beneath it, the count swaps inside a fixed-width slot so a 3 becoming a 4 never nudges the pill, and taking your reaction back reverses the same path. Every glyph is inline SVG rather than an emoji character, so reactions inherit the interface's type color instead of the reader's platform font.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Chat thread
A tapback attaches to the corner of the bubble it was left on.