Like burst
The heart pops and fills, a ring and eight dots burst outward, and the count rolls up to the new number.
It imports two small hooks from @/lib/motion and the colour tokens; the zip includes both.
Demo.tsx
"use client";
import { useState } from "react";
import { usePrefersReducedMotion } from "@/lib/motion";
import styles from "./demo.module.css";
const DOTS = Array.from({ length: 8 }, (_, i) => i);
export default function Demo() {
const reduced = usePrefersReducedMotion();
const [liked, setLiked] = useState(false);
const [count, setCount] = useState(128);
const [prev, setPrev] = useState<number | null>(null);
const [burst, setBurst] = useState(0);
const toggle = () => {
setPrev(count);
setCount(count + (liked ? -1 : 1));
setLiked(!liked);
if (!liked) setBurst(burst + 1);
};
const dir = liked ? styles.up : styles.down;
return (
<div className={styles.root}>
<div className={styles.card}>
<div className={styles.text}>
<p className={styles.title}>Casa Mar</p>
<p className={styles.sub}>Lisbon, 3 nights from €410</p>
</div>
<button type="button" aria-pressed={liked} className={`${styles.like} ${liked ? styles.liked : ""}`} onClick={toggle}>
<span className={styles.heartWrap} aria-hidden="true">
{burst > 0 && liked && !reduced && (
<span key={burst} className={styles.burst}>
<span className={styles.ring} />
{DOTS.map((i) => (
<span key={i} className={styles.dot} style={{ "--a": `${i * 45}deg` } as React.CSSProperties} />
))}
</span>
)}
<svg key={String(liked)} className={styles.heart} viewBox="0 0 24 24" width="22" height="22">
<path d="M12 20s-7.5-4.6-7.5-10.2A4.3 4.3 0 0 1 12 7.2a4.3 4.3 0 0 1 7.5 2.6C19.5 15.4 12 20 12 20Z" />
</svg>
</span>
<span className={styles.sr}>Like</span>
<span className={styles.count}>
{prev !== null && (
<span key={`p${prev}${count}`} className={`${styles.out} ${dir}`} aria-hidden="true">{prev}</span>
)}
<span key={`c${count}`} className={prev !== null ? `${styles.in} ${dir}` : ""}>{count}</span>
</span>
</button>
</div>
</div>
);
}
demo.module.css
.root { width: 100%; height: 100%; display: grid; place-items: center; padding: 16px; }
.card {
width: min(100%, 360px); display: flex; align-items: center; justify-content: space-between; gap: 16px;
padding: 16px 16px 16px 20px; background: var(--white); border: 1px solid var(--line); border-radius: 16px;
}
.text { display: grid; gap: 2px; min-width: 0; }
.title { font-size: 16px; font-weight: 650; }
.sub { font-size: 13px; color: var(--muted); }
.like {
flex: none; display: inline-flex; align-items: center; gap: 8px; min-height: 44px; padding: 0 16px 0 12px;
border-radius: 999px; border: 1px solid var(--line); background: var(--white); cursor: pointer;
font-size: 15px; font-weight: 600; font-variant-numeric: tabular-nums; -webkit-tap-highlight-color: transparent;
transition: border-color .2s, background-color .2s, transform .18s var(--out);
}
.like:hover { border-color: var(--ink); }
.like:active { transform: scale(.95); }
.liked { background: var(--accent-pale); border-color: var(--accent-soft); }
.liked:hover { border-color: var(--accent-text); }
.heartWrap { position: relative; display: grid; place-items: center; width: 22px; height: 22px; }
.heart { display: block; fill: transparent; stroke: var(--ink); stroke-width: 1.8; stroke-linejoin: round; transition: fill .2s; }
.liked .heart { fill: var(--accent-text); stroke: var(--accent-text); animation: pop .45s var(--out); }
@keyframes pop { 0% { scale: .5; } 45% { scale: 1.3; } 100% { scale: 1; } }
.burst { position: absolute; inset: 0; pointer-events: none; }
.ring {
position: absolute; inset: -2px; border-radius: 50%; border: 2px solid var(--accent);
animation: ring .5s var(--out) forwards;
}
@keyframes ring { from { scale: .2; opacity: 1; } to { scale: 1.9; opacity: 0; border-width: .5px; } }
.dot {
position: absolute; left: 50%; top: 50%; width: 5px; height: 5px; margin: -2.5px; border-radius: 50%;
background: var(--accent); animation: dot .6s var(--out) .06s both;
}
.dot:nth-child(odd) { background: var(--accent-text); }
@keyframes dot {
from { transform: rotate(var(--a)) translateY(-8px) scale(1.2); }
to { transform: rotate(var(--a)) translateY(-24px) scale(0); }
}
.count { display: inline-grid; overflow: hidden; height: 1.3em; line-height: 1.3em; }
.count > span { grid-area: 1 / 1; text-align: left; }
.in.up { animation: inUp .35s var(--out); }
.in.down { animation: inDown .35s var(--out); }
.out { animation: .35s var(--out) forwards; }
.out.up { animation-name: outUp; }
.out.down { animation-name: outDown; }
@keyframes inUp { from { translate: 0 100%; } }
@keyframes inDown { from { translate: 0 -100%; } }
@keyframes outUp { to { translate: 0 -100%; opacity: 0; } }
@keyframes outDown { to { translate: 0 100%; opacity: 0; } }
.sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
@media (prefers-reduced-motion: reduce) {
.liked .heart, .in.up, .in.down { animation: none; }
.out { display: none; }
}
Use it for
- Saving or liking listings, posts and photos
- Social reactions where the moment of liking should feel good
Avoid
- Serious products such as banking or health records
- Grids with dozens of hearts, where bursts become noise
How it works
One click flips aria-pressed and the count. The burst element is keyed by a counter so every new like remounts it and replays its CSS keyframes: a ring scales from .2 to 1.9 while fading, and eight dots each rotate by a --a custom property (i times 45deg) and translate outward while shrinking. The heart svg is keyed by the liked state so its pop replays. The old and new numbers sit in one grid cell and slide out and in vertically, up for a like and down for an unlike.
Accessibility
It is a toggle button with aria-pressed and a hidden Like label read together with the current count; the outgoing number is aria-hidden. Reduced motion skips the burst, the pop and the roll and just shows the filled heart and new count.