Like Burst
Tapping the heart squashes it, refills it in colour and throws a ring of small sparks outward.
Create a React like button with a CSS burst. The button holds a heart SVG and toggles aria-pressed. When it turns on: scale the heart 1 → 0.8 → 1.15 → 1 over 450ms with cubic-bezier(0.16, 1, 0.3, 1) and switch its fill from transparent to the accent; at the same time play a ring of 8 small dots positioned around the heart (rotate(calc(var(--i) * 45deg)) translateY(-10px)) that move out to translateY(-30px) and fade from opacity 1 to 0 over 500ms. Restart the burst on every like by changing a key. Turning it off just empties the heart, with no burst. Show the count next to it with tabular-nums. Use aria-label "Like" and update it to "Liked". Under prefers-reduced-motion, skip the scale and the sparks and only change the fill.
Source
'use client'
import { useState } from 'react'
import s from './demo.module.css'
export default function LikeBurst({ labels }: { labels: Record<string, string> }) {
const [liked, setLiked] = useState(false)
const [burst, setBurst] = useState(0)
const toggle = () => {
if (!liked) setBurst(burst + 1)
setLiked(!liked)
}
return (
<button type="button" className={s.button} aria-pressed={liked} aria-label={liked ? labels.liked : labels.like} onClick={toggle}>
<span className={s.icon}>
<svg key={burst} className={s.heart} viewBox="0 0 24 24" aria-hidden="true">
<path d="M12 20.5s-7.5-4.6-7.5-10.2A4.3 4.3 0 0 1 12 7.6a4.3 4.3 0 0 1 7.5 2.7c0 5.6-7.5 10.2-7.5 10.2Z" />
</svg>
{liked && (
<span key={`b${burst}`} className={s.burst} aria-hidden="true">
{Array.from({ length: 8 }, (_, i) => (
<i key={i} style={{ '--i': i } as React.CSSProperties} />
))}
</span>
)}
</span>
<span className={s.count}>{liked ? 129 : 128}</span>
</button>
)
}
.button {
display: inline-flex;
align-items: center;
gap: 10px;
height: 52px;
padding: 0 20px 0 16px;
border: 1px solid var(--stage-rule);
border-radius: 999px;
background: var(--stage-surface);
color: var(--stage-ink);
font: 600 var(--text-base) / 1 var(--font-body);
cursor: pointer;
}
.button:hover { border-color: var(--stage-muted); }
.button:focus-visible { outline: 2px solid var(--color-focus); outline-offset: 3px; }
.icon { position: relative; display: grid; place-items: center; width: 26px; height: 26px; }
.heart { width: 26px; height: 26px; overflow: visible; }
.heart path {
fill: transparent;
stroke: var(--stage-ink);
stroke-width: 1.8;
transition: fill 150ms linear, stroke 150ms linear;
}
.button[aria-pressed='true'] .heart { animation: pop 450ms var(--ease-out); }
.button[aria-pressed='true'] .heart path { fill: var(--stage-accent); stroke: var(--stage-accent); }
@keyframes pop {
0% { transform: scale(1); }
25% { transform: scale(0.8); }
60% { transform: scale(1.15); }
100% { transform: scale(1); }
}
.burst { position: absolute; inset: 0; pointer-events: none; }
.burst i {
position: absolute;
top: 50%;
left: 50%;
width: 5px;
height: 5px;
margin: -2.5px;
border-radius: 50%;
background: var(--stage-accent);
transform: rotate(calc(var(--i) * 45deg)) translateY(-10px);
animation: spark 500ms var(--ease-out) forwards;
}
.burst i:nth-child(even) { background: var(--stage-ink); }
@keyframes spark {
to { transform: rotate(calc(var(--i) * 45deg)) translateY(-30px); opacity: 0; }
}
.count { font-variant-numeric: tabular-nums; }
@media (prefers-reduced-motion: reduce) {
.button[aria-pressed='true'] .heart { animation: none; }
.burst { display: none; }
}