Like Button
The heart takes on its fill and settles once — no particle spray, no confetti.
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 { useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
/**
* Vibary · Like Button
*
* The glyph fills and settles once. No particle spray: a like is the
* cheapest action in any product, it fires dozens of times per session,
* and motion that is delightful on the first tap is exhausting on the
* twentieth. What moves is the heart taking on its fill and the count
* stepping by one.
*
* Self-contained: depends only on `motion` (react ships with your app).
* The pill is mixed from the inherited text color, so it reads correctly
* on a light page and on a dark one.
* Works with zero props; tune via `variant`, `baseCount`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type LikeButtonBurstProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Uncontrolled starting state. */
defaultLiked?: boolean;
/** Count before the viewer's own like. */
baseCount?: number;
/** Accessible verb for the control. */
label?: string;
/** Fires on every toggle, with the new state. */
onLikedChange?: (liked: boolean) => void;
};
type VariantConfig = {
/** Scale the filled glyph starts from. */
from: number;
spring: { type: "spring"; stiffness: number; damping: number };
/** px the count travels as it rolls. */
roll: number;
rollSeconds: number;
};
// Every spring here is at or above a 0.8 damping ratio
// (damping / 2√stiffness), so the fill settles once. A glyph this small
// turns visible wobble into a rendering glitch rather than personality —
// and the variants differ in travel and speed, never in bounce count.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// Almost a crossfade. For a dense feed where every row has one.
subtle: {
from: 0.82,
spring: { type: "spring", stiffness: 520, damping: 42 },
roll: 9,
rollSeconds: 0.2,
},
// One soft settle — enough to feel like the tap did something.
default: {
from: 0.68,
spring: { type: "spring", stiffness: 420, damping: 34 },
roll: 12,
rollSeconds: 0.24,
},
// More travel into the settle, for a detail view where the like is
// the primary action on screen.
playful: {
from: 0.55,
spring: { type: "spring", stiffness: 360, damping: 31 },
roll: 14,
rollSeconds: 0.26,
},
};
/** Semantic color: a like is red in every product that has one. */
const LIKE = "#E8365D";
const HEART =
"M12 20.6C12 20.6 3.2 15.1 3.2 9.2 3.2 6.3 5.5 4.2 8.2 4.2 9.9 4.2 11.3 5.1 12 6.4 12.7 5.1 14.1 4.2 15.8 4.2 18.5 4.2 20.8 6.3 20.8 9.2 20.8 15.1 12 20.6 12 20.6Z";
/** Theme-adaptive neutral: `currentColor` is the text color in scope —
* near-black on a light page, near-white on a dark one — so mixing it
* with `transparent` yields a surface or border correctly toned in
* either theme. Nothing to configure. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
export default function LikeButtonBurst({
variant = "default",
defaultLiked = false,
baseCount = 1284,
label = "Like",
onLikedChange,
}: LikeButtonBurstProps) {
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
const [liked, setLiked] = useState(defaultLiked);
const count = baseCount + (liked ? 1 : 0);
const toggle = () => {
const next = !liked;
setLiked(next);
onLikedChange?.(next);
};
// Reduced motion: the fill still arrives and the number still changes.
// Only the travel to get there is dropped — the state is never carried
// by movement alone.
const fillTransition = reduceMotion
? { duration: 0.16, ease: "easeOut" as const }
: { ...cfg.spring, opacity: { duration: 0.14, ease: "easeOut" as const } };
// Direction of the roll follows the direction of the change: up when
// the number grows, down when the like is taken back.
const rollFrom = reduceMotion ? 0 : liked ? cfg.roll : -cfg.roll;
return (
<button
type="button"
onClick={toggle}
aria-pressed={liked}
aria-label={`${label} · ${count.toLocaleString()} likes`}
style={{
display: "inline-flex",
alignItems: "center",
gap: 9,
padding: "7px 13px 7px 10px",
borderRadius: 999,
border: `1px solid ${tone(13)}`,
background: tone(5),
color: "inherit",
fontFamily: "inherit",
fontSize: 13.5,
lineHeight: 1,
cursor: "pointer",
}}
>
<span
aria-hidden
style={{
position: "relative",
display: "block",
width: 20,
height: 20,
}}
>
{/* Two stacked copies of one path: the outline hands over to the
fill. Crossfading them is cheaper to reason about than
interpolating a stroke into a fill, and it keeps the resting
state theme-adaptive via `currentColor`. */}
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
style={{ position: "absolute", inset: 0, display: "block" }}
>
<motion.path
d={HEART}
stroke="currentColor"
strokeWidth="1.7"
strokeLinejoin="round"
initial={false}
animate={{ opacity: liked ? 0 : 0.55 }}
transition={{ duration: 0.14, ease: "easeOut" }}
/>
</svg>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
style={{ position: "absolute", inset: 0, display: "block" }}
>
<motion.path
d={HEART}
fill={LIKE}
initial={false}
animate={{ scale: liked ? 1 : cfg.from, opacity: liked ? 1 : 0 }}
transition={fillTransition}
// `fill-box` makes the origin relative to the glyph itself
// rather than the SVG viewport, so it scales about its own
// centre of mass rather than about the corner.
style={{ transformBox: "fill-box", transformOrigin: "50% 55%" }}
/>
</svg>
</span>
{/* The count is a number, so it rolls at a constant size — text
that scales or bounces is the fastest way to make a UI look
cheap. The button's aria-label already carries the value. */}
<span
aria-hidden
style={{
position: "relative",
display: "block",
minWidth: 40,
height: 16,
overflow: "hidden",
fontVariantNumeric: "tabular-nums",
}}
>
<AnimatePresence initial={false}>
<motion.span
key={count}
initial={{ y: rollFrom, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: -rollFrom, opacity: 0 }}
transition={{
duration: reduceMotion ? 0.14 : cfg.rollSeconds,
ease: [0.32, 0.72, 0, 1],
}}
style={{
position: "absolute",
left: 0,
top: 0,
display: "block",
lineHeight: "16px",
opacity: 0.75,
}}
>
{count.toLocaleString()}
</motion.span>
</AnimatePresence>
</span>
</button>
);
}About this pattern
A like is the cheapest action in a product: it fires dozens of times per session, often twice in a row when someone changes their mind. Motion that is delightful on the first tap is exhausting on the twentieth, so this deliberately does not spray particles. The outline glyph hands over to a filled one that arrives with a single soft settle, and the count steps by one on a short vertical roll at a constant size. Everything else in the row stays still.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Comment thread
The glyph fills and the count beside it steps up by one.
Related patterns
- Message Reaction AttachThe chosen glyph flies out of the picker and docks at the corner of the bubble it belongs to.
- Reaction PickerHold a message and the reactions fan out along a shallow arc above it.
- Group JoinA joining member's avatar drops into the stack, the others make room, and the count rolls by one.