← Back to gallery
MIXED

Click Replay Heart Burst

Three like-button burst treatments — a Heart Pop (a single one-shot scale-pop replays via element.animate() on every click), a Radial Particle Burst (the same heart pop layered with six decorative particles that fly outward), and a Balloon Rain (a celebration variant where eighteen heart-shaped balloons drift upward on a CSS @keyframes loop with a ripple ring under the button — same look as the gallery thumbnail card-preview, scaled up). The two WAAPI variants cancel any in-flight animations before kicking off the next so rapid clicks never stack easing curves.

like-buttonburstparticlemicro-interactionwaapiaria-pressedprefers-reduced-motion

Micro-interaction · WAAPI replay · element.animate()

Click Replay Heart Burst

Two like-button burst treatments — a Heart Pop (a single one-shot scale-pop replays via element.animate() on every click) and a Particle Burst (the same heart pop layered with six decorative particles that fly outward in radial directions). Both cancel any in-flight animation before kicking off the next one so rapid clicks never stack easing curves; the decorative particle layer lives outside the button's accessible name. Stage cards auto-replay the click every 3.6s so the burst plays without input; inspector preview is a real interactive button with Tab captured to refocus it.

WAAPI · scale-pop replay

Heart Pop Replay

A single heart icon scale-pops on every click via element.animate(). Liked state tracks aria-pressed; the pop ignores the toggle so the feedback plays whether the click turned the heart on or off. Cancel the previous WAAPI animation before kicking off the next, or rapid clicks stack easing curves and the pop drifts.

  • waapi
  • scale pop
  • aria-pressed

WAAPI · heart + radial particles

Radial Particle Burst

Six decorative dots ride on a separate aria-hidden layer. Each click replays both the heart pop and a fresh particle burst via element.animate() so the effect feels alive under repeat taps. Particles travel along radial vectors (cos / sin) so they exit evenly around the heart.

  • particles
  • multi-layer
  • pointer-events: none

CSS · floating heart balloons + ripple

Balloon Rain

A celebration variant — eighteen heart-shaped balloons drift upward on a CSS @keyframes loop while the like button pulses and a ripple ring radiates out underneath. Pure CSS so the effect plays without JS scheduling; the same look as the gallery thumbnail card-preview, scaled up.

  • css-keyframes
  • celebration
  • no-js

Heart burst inspector

Heart Pop Replay

click heart
  • waapi
  • scale pop
  • aria-pressed

A single heart icon scale-pops on every click via element.animate(). Liked state tracks aria-pressed; the pop ignores the toggle so the feedback plays whether the click turned the heart on or off. Cancel the previous WAAPI animation before kicking off the next, or rapid clicks stack easing curves and the pop drifts.

Helped you ship something? 🐟 Send my cat a churu

// One-shot heart pop on every click. Cancel in-flight animations first
// or rapid clicks will stack easing curves and the pop will drift.
const heart = button.querySelector('.heart-icon');

button.addEventListener('click', () => {
  heart.getAnimations().forEach((anim) => anim.cancel());
  heart.animate(
    [
      { transform: 'scale(1)' },
      { transform: 'scale(1.30)' },
      { transform: 'scale(1)' }
    ],
    { duration: 420, easing: 'cubic-bezier(0.22, 1, 0.36, 1)' }
  );
  button.toggleAttribute('data-liked');
});

How to make this

A replayable heart burst separates the accessible like button from aria-hidden decoration, then restarts a transform-only heart pop and optional particles on every click.

html
1<button class="heart-burst" type="button" aria-pressed="false">2  <span class="heart-burst__particles" aria-hidden="true">    <i></i><i></i><i></i><i></i><i></i><i></i>  </span>  <span class="heart-burst__icon" aria-hidden="true">♥</span>  <span class="heart-burst__label">Like</span></button> <style>.heart-burst {  position: relative;  display: inline-flex;  align-items: center;  gap: .55rem;  padding: .7rem 1rem;  border: 1px solid #334155;  border-radius: 999px;  background: #0f172a;  color: #cbd5e1;  font: 700 1rem/1 sans-serif;  cursor: pointer;}.heart-burst__icon {  color: #f43f5e;  transform-origin: center;}.heart-burst:active .heart-burst__icon {28  animation: heart-burst-pop 420ms cubic-bezier(.22,1,.36,1);}.heart-burst__particles {  position: absolute;  left: 1.25rem;  top: 50%;  pointer-events: none;35}.heart-burst__particles i {  position: absolute;  width: 6px;  aspect-ratio: 1;  border-radius: 50%;  background: #ec4899;  opacity: 0;}.heart-burst:active .heart-burst__particles i {  animation: heart-burst-dot 520ms cubic-bezier(.33,1,.68,1);}.heart-burst__particles i:nth-child(1) { --x: 34px; --y: 0; }48.heart-burst__particles i:nth-child(2) { --x: 17px; --y: 29px; }.heart-burst__particles i:nth-child(3) { --x: -17px; --y: 29px; }.heart-burst__particles i:nth-child(4) { --x: -34px; --y: 0; }.heart-burst__particles i:nth-child(5) { --x: -17px; --y: -29px; }.heart-burst__particles i:nth-child(6) { --x: 17px; --y: -29px; }@keyframes heart-burst-pop {  0% { transform: scale(1); }  42% { transform: scale(1.32); }  100% { transform: scale(1); }}@keyframes heart-burst-dot {  0% { opacity: 1; transform: translate(0,0) scale(.2); }  100% { opacity: 0; transform: translate(var(--x), var(--y)) scale(1); }}@media (prefers-reduced-motion: reduce) {  .heart-burst:active .heart-burst__icon,64  .heart-burst:active .heart-burst__particles i {    animation: none;  }}</style>

Annotated snippet

  1. Line 1Use a real button for the like control. The visual burst must not replace aria-pressed or a state label, because the animation is not an accessible state change by itself.
    PitfallHow should a like button expose state to screen readers?

    Use a real button with aria-pressed and update the accessible label between Like and Unlike. Keep the heart SVG and particles aria-hidden unless their text is part of the intended button name. The burst is feedback, not the source of truth.

  2. Line 2The particle layer is aria-hidden. Decorative dots should never become part of the button name or receive pointer events.
  3. Line 28The icon animation is transform-only, so the count and label do not shift. In production, replay this with element.animate() and cancel in-flight animations before starting the next one.

    Both buttons pulse the heart on the same 1.4s cycle. Animating font-size also expands the icon, but the count and label next to it get pushed sideways on every beat. Animating transform: scale leaves the layout box at its original size — neighbours stay anchored.

    PitfallAre heart burst particles expensive?

    Small transform-and-opacity particles are cheap, but many blurred particles or layout-changing effects can add up. Keep particles absolutely positioned, animate transform and opacity only, and cancel old animations so rapid clicks do not stack work.

  4. Line 35pointer-events: none keeps the decorative layer from stealing clicks from the button. This matters when particles extend outside the icon area.
  5. Line 48Each particle gets its own vector through custom properties. The same keyframe can send dots in radial directions without duplicating six animations.

    Both cards run the same keyframe and the same duration. Without per-particle vectors the dots all travel the same direction, reading as a single beam. With each dot owning its own --dx / --dy custom property, the keyframe expands into a radial burst.

    PitfallHow do I replay a heart burst on every click?

    For production, use the Web Animations API: call getAnimations().forEach(cancel) on the heart and particles, then call element.animate() again for the new click. CSS :active is fine for a minimal snippet, but it cannot reliably restart long-running bursts under rapid repeated clicks.

  6. Line 64Reduced motion suppresses the burst animations while preserving the button interaction. The liked state should still toggle immediately.
    PitfallHow should click bursts handle prefers-reduced-motion?

    Skip the burst and particle travel when prefers-reduced-motion is active, but keep the state change. A user who reduces motion still needs immediate confirmation through aria-pressed, label text, count changes, or a static color change.

Other pitfalls

Which browsers support element.animate for replay effects?
Modern Chromium, Firefox, and Safari support the Web Animations API for basic transform and opacity keyframes. If WAAPI is unavailable, the button should still toggle state and can fall back to a simple CSS :active pop.
Advanced

Spawn a dozen floating mini-hearts in varied sizes and tints

Same heart pop in the center on both sides. BEFORE shows the base burst: six identical pink dots scattering outward in a symmetric pattern. AFTER swaps those dots for twelve mini-hearts with varied sizes (14-28px), warm and cool pink tints, asymmetric drift, and randomised rotation, plus a shockwave ring on the icon — they spawn in sequence (60-70ms stagger) and float UP instead of just scattering. The richer field reads as a delight beat that telegraphs brand identity.

View explanation and full code114 lines

The base recipe handles a heart pop plus six identical pink dots scattering outward — fine for a baseline burst. Real production like buttons (Twitter, Instagram, Threads) replace those uniform dots with a richer field: 12+ mini-hearts of varied sizes, warm and cool pink tints, asymmetric drift, and randomised rotation, floating UP and dispersing instead of just scattering. The result reads as a delight beat that telegraphs identity instead of a generic particle effect. A shockwave ring on the heart icon adds extra punctuation. All :active-triggered, fully restartable on every click.

Paste this as a complete alternative to the base recipe — it replaces the six-dot scatter with twelve styled hearts plus a shockwave ring.

html
<button class="heart-burst" type="button" aria-pressed="false">  <span class="heart-burst__field" aria-hidden="true">    <i>♥</i><i>♥</i><i>♥</i><i>♥</i>    <i>♥</i><i>♥</i><i>♥</i><i>♥</i>    <i>♥</i><i>♥</i><i>♥</i><i>♥</i>  </span>  <span class="heart-burst__icon" aria-hidden="true">♥</span>  <span class="heart-burst__label">Like</span></button> <style>.heart-burst {  position: relative;  display: inline-flex;  align-items: center;  gap: .55rem;  padding: .7rem 1rem;  border: 1px solid #334155;  border-radius: 999px;  background: #0f172a;  color: #cbd5e1;  font: 700 1rem/1 sans-serif;  cursor: pointer;  overflow: visible;}.heart-burst__icon {  position: relative;  color: #f43f5e;  font-size: 1.15rem;  line-height: 1;  transform-origin: center;}.heart-burst__icon::after {  content: '';  position: absolute;  left: 50%;  top: 50%;  width: 20px;  height: 20px;  margin: -10px 0 0 -10px;  border-radius: 999px;  border: 1.5px solid #f43f5e;  pointer-events: none;  opacity: 0;  transform: scale(0);}.heart-burst:active .heart-burst__icon {  animation: heart-burst-pop 550ms cubic-bezier(.34,1.56,.64,1);}.heart-burst:active .heart-burst__icon::after {  animation: heart-burst-ring 700ms cubic-bezier(.22,1,.36,1);}.heart-burst__field {  position: absolute;  left: 1.25rem;  top: 50%;  width: 0;  height: 0;  pointer-events: none;}.heart-burst__field i {  position: absolute;  left: 0;  top: 0;  color: var(--c);  font-size: var(--s);  font-style: normal;  line-height: 1;  opacity: 0;  transform: translate(-50%, -50%) scale(.3);  text-shadow: 0 1px 3px rgba(0, 0, 0, .25);}.heart-burst:active .heart-burst__field i {  animation: heart-burst-float 2.6s cubic-bezier(.39, .06, .51, .99) forwards;  animation-delay: calc(var(--d) * 1ms);}.heart-burst__field i:nth-child(1)  { --s: 14px; --c: #ff6b9d; --x:  28px; --y: -110px; --r:  12deg; --d:   0; }.heart-burst__field i:nth-child(2)  { --s: 22px; --c: #e8487e; --x: -24px; --y: -150px; --r: -16deg; --d:  60; }.heart-burst__field i:nth-child(3)  { --s: 16px; --c: #f49cb9; --x:  44px; --y:  -90px; --r:   8deg; --d: 130; }.heart-burst__field i:nth-child(4)  { --s: 28px; --c: #ff8fb3; --x: -50px; --y: -170px; --r:  -8deg; --d: 200; }.heart-burst__field i:nth-child(5)  { --s: 18px; --c: #d4537e; --x:  18px; --y: -130px; --r:  18deg; --d: 270; }.heart-burst__field i:nth-child(6)  { --s: 20px; --c: #ffa6c9; --x: -32px; --y: -140px; --r: -20deg; --d: 340; }.heart-burst__field i:nth-child(7)  { --s: 16px; --c: #c4365f; --x:  54px; --y: -160px; --r:   5deg; --d: 410; }.heart-burst__field i:nth-child(8)  { --s: 20px; --c: #ff5c8a; --x: -44px; --y: -100px; --r: -10deg; --d: 480; }.heart-burst__field i:nth-child(9)  { --s: 24px; --c: #ff6b9d; --x:  12px; --y: -180px; --r:  15deg; --d: 550; }.heart-burst__field i:nth-child(10) { --s: 14px; --c: #f49cb9; --x: -20px; --y: -120px; --r:  -6deg; --d: 620; }.heart-burst__field i:nth-child(11) { --s: 22px; --c: #e8487e; --x:  60px; --y: -140px; --r:  22deg; --d: 690; }.heart-burst__field i:nth-child(12) { --s: 18px; --c: #ff8fb3; --x: -55px; --y: -130px; --r: -18deg; --d: 760; }@keyframes heart-burst-pop {  0%   { transform: scale(1); }  18%  { transform: scale(1.32); }  34%  { transform: scale(.94); }  50%  { transform: scale(1.14); }  68%  { transform: scale(.98); }  100% { transform: scale(1); }}@keyframes heart-burst-ring {  0%   { opacity: .9; transform: scale(0); }  60%  { opacity: .35; }  100% { opacity: 0; transform: scale(2.4); }}@keyframes heart-burst-float {  0%   { opacity: 0; transform: translate(-50%, -50%) scale(.3) rotate(0); }  12%  { opacity: 1; }  80%  { opacity: .9; }  100% { opacity: 0;         transform: translate(calc(-50% + var(--x)), calc(-50% + var(--y))) scale(.9) rotate(var(--r)); }}@media (prefers-reduced-motion: reduce) {  .heart-burst:active .heart-burst__icon,  .heart-burst:active .heart-burst__icon::after,  .heart-burst:active .heart-burst__field i { animation: none; }}</style>

Notes

Overview

The heart-burst like button replays a scale-pop on every click via element.animate() (Web Animations API). Each click cancels in-flight animations before kicking off the next one — otherwise rapid clicks stack easing curves and the bounce drifts. A particle variant adds radial dots that travel outward in evenly-spaced angles for an exuberant celebration feel.

When to use it

Reach for the heart burst on like buttons, follow toggles, favorite stars, anywhere a click toggles a binary state that deserves visual celebration. Skip it on toggles where the action is purely utility (mute, theme switch) — the burst feels excessive for non-celebratory toggles. Skip the particle variant on dense lists; six particles per click across many list items burns frame budget.

How it works

On click, walk element.getAnimations() and call cancel() on each in-flight animation before starting the next. The pop itself is a Web Animations API element.animate() call with a three-keyframe scale array (1 → 1.25 → 1) and an overshoot easing curve like cubic-bezier(.34,1.56,.64,1). The overshoot gives the bounce its character — a plain ease-out reads as lifeless. The particle variant spawns six absolutely-positioned dots in a sibling layer, each animated outward via translate(cos(angle) * radius, sin(angle) * radius) with a staggered easing for organic spread.

Production gotchas

Web Animations API support is solid in modern browsers but the getAnimations() filter is still gated behind a flag on older WebKit — gate the cancel-all call behind a feature detect. The cubic-bezier overshoot can push the heart visibly past adjacent layout if the parent container has tight overflow: hidden — either widen the container or clamp the peak scale. Particles spawned via appendChild need cleanup on animationend or the DOM grows unbounded under rapid clicking.

Accessibility

The button itself is a real <button> with aria-pressed reflecting like state; the bounce is decoration only. Under prefers-reduced-motion: reduce swap the pop for a brief color/fill change so the toggle still communicates state without scale motion. The particle variant should be suppressed entirely in reduced-motion mode — outward-radiating dots are exactly the kind of peripheral motion the user opted out of.

References

Implementation depth

Replayable bursts need a clean separation between button state and particles. aria-pressed belongs to the like button; the WAAPI or CSS particles are temporary confirmation that can be skipped.

Pointer origin improves feel but should not be required. Provide a centered fallback for keyboard activation, prevent particles from stealing focus, and reduce motion by showing the pressed state without the burst.