GODRICH
#css-animation#button#loader#transition#scroll#dashboard#ui#design-trends

9 CSS Heart Animations — One Line, Copy-Paste

These nine css heart animations turn a checkbox or a radio input into a like button, a star rating, or a reaction picker, moving only a transform or an opacity

Auto-plays · click a tile to jump to its section · all nine in one zip

The order of these css button animation examples follows how directly each control answers a click, not popularity. A heart filling in comes first (01–02), then a number that has to actually change (03), then rating widgets that either record a pick or just display a value (04–05). After that comes a counter built to be pressed over and over (06), a bookmark meant to be checked once and found later (07), and a confirmation gesture (08). Last comes a group where picking one option affects every sibling around it (09). Watching the grid above needs no clicking at all: every tile there loops on its own timer, since it sits inside a still iframe that can never receive a real pointer. Scroll past it once before copying anything out of the sections below.

01Heart pop

What scales up

Checking the heart's hidden checkbox scales its filled layer from 0.4 up past 1.15 and settles at a full 1, anchored on the shape's own center rather than the wrapper around it.

Heart pop
Open in a new tab

01 · Heart pop — Measured area 1.06% · moving frames 20/23Original by GODRICH · SCSS and React ship in the zip · See the password

::before/::after 하트:checked0.22초

Where it fits

It suits a post's like button or a product's wishlist icon, anywhere the confirmation needs to live inside the icon itself.

.heart__shape--fill {
  opacity: 0;
  transform: scale(.4) rotate(-8deg);
  transition: opacity $duration $easing, transform $duration $easing;
}
.heart__input:checked ~ .heart__icon .heart__shape--fill {
  opacity: 1;
  transform: scale(1) rotate(0deg);
}
@keyframes heart-pop-loop {
  0%, 15%  { opacity: 0; transform: scale(.4) rotate(-8deg); }
  35%      { opacity: 1; transform: scale(1.15) rotate(6deg); }
  55%, 80% { opacity: 1; transform: scale(1) rotate(0deg); }
  100%     { opacity: 0; transform: scale(.4) rotate(-8deg); }
}

02Particle burst

Eight dots, eight angles

Eight small dots sit stacked at the heart's own center, each holding a different index in the custom property --i that a calc() turns into its own angle. Checking the box sends every dot flying outward along that angle while it fades through opacity.

Particle burst
Open in a new tab

02 · Particle burst — Measured area 1.614% · moving frames 18/23Original by GODRICH · SCSS and React ship in the zip · See the password

--i 커스텀 속성translate+opacity스태거

Staggering the burst

Multiplying --i by a fixed delay staggers the eight so no two leave at exactly the same millisecond, a heavier flourish than item 01's plain pop for a first-like nudge in onboarding.

.burst__dot {
  border-radius: 50%;
  opacity: 0;
  transform: rotate(calc(var(--i) * 45deg)) translateY(0) scale(1);
}
.burst__input:checked ~ .burst__icon .burst__dot {
  animation: particle-fly $duration $easing forwards;
  animation-delay: calc(var(--i) * 20ms);
}
@keyframes particle-fly {
  0%   { opacity: 1; transform: rotate(calc(var(--i) * 45deg)) translateY(0) scale(1); }
  70%  { opacity: .7; }
  100% { opacity: 0; transform: rotate(calc(var(--i) * 45deg)) translateY(-34px) scale(.2); }
}

03Like count sync

Why a css like button animation still needs JavaScript

CSS alone cannot rewrite a text node, so a handful of JavaScript lines swap the neighboring number by one on every change event while a scale keyframe on that same digit gives the update a brief pop.

Like count sync
Open in a new tab

03 · Like count sync — Measured area 0.179% · moving frames 10/23Original by GODRICH · SCSS and React ship in the zip · See the password

JS 카운트scale pop:checked

Where to drop it in

A card that already prints a like count or a comment's upvote total can drop the same few lines in without touching its checkbox markup.

function updateCount() {
  count.textContent = input.checked ? "13" : "12";
  count.classList.remove("is-pop");
  void count.offsetWidth;
  count.classList.add("is-pop");
}
input.addEventListener("change", updateCount);

04Star fill

Five radios in reverse

Five radios sit in the markup in reverse, five down to one, so a single sibling selector after the checked input colors every star that follows it in source order — which on screen reads as every star up to the pick.

Star fill
Open in a new tab

04 · Star fill — Measured area 2.926% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

~ 형제 선택자fieldset/legendscale 바운스

Confirming the pick

The one radio actually checked gets an extra scale bounce through the adjacent-sibling selector, confirming exactly which star the tap landed on. At the same instant four mini stars burst off the picked star's glyph, each carrying its own mass (0.6–1.4) so that a semi-implicit Euler integration inside requestAnimationFrame drags it down with damping (-k/m·v — heavier decays slower) and gravity until it stops.

.rating__stars { display: flex; flex-direction: row-reverse; }
.rating__input:checked ~ .rating__star { color: $color; }
.rating__input:checked + .rating__star { animation: star-pick $duration $easing; }
@keyframes star-pick {
  0%   { transform: scale(1); }
  45%  { transform: scale(1.35); }
  100% { transform: scale(1); }
}

05Half-star width fill

A badge, not an input

This one is not an input at all but a read-only average-rating badge, where a single custom property --rating feeds a calc() that becomes a plain percentage.

Half-star gradient display
Open in a new tab

05 · Half-star gradient display — Measured area 1.199% · moving frames 17/23Original by GODRICH · SCSS and React ship in the zip · See the password

--rating 커스텀 속성overflow 폭 클리핑루프 리빌

Why width tweens

Because that percentage lands on width, a standard property, the browser already knows how to tween it, so the fill animates smoothly from zero up to the decimal rating on a repeating loop, not just once when the badge first appears. The moment the fill reaches its leading edge (the --pct point), six mini stars burst right from that edge, driven by the same mass-based damping (-k/m·v) and mass-independent gravity integrated frame by frame in requestAnimationFrame.

.avg__fill {
  position: absolute;
  inset: 0;
  width: 0%;
  overflow: hidden;
}
@keyframes reveal-fill {
  0%       { width: 0%; }
  55%, 80% { width: var(--pct); }
  100%     { width: var(--pct); }
}
.avg__fill { animation: reveal-fill $dur-loop $easing infinite; }

06Clap count

Two animations from one class

Every press adds one to the count while a single class swap fires two animations together: the icon rotates through a quick bounce and a small "+1" label drifts upward before fading out above it.

Clap count
Open in a new tab

06 · Clap count — Measured area 0.886% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

반복 클릭translateY+opacityJS 카운트

Keeping the plus-one in step

Sharing one class between both keeps the plus-one from hanging around after the icon's bounce has already finished. Each clap also throws a short burst of five mini stars from the icon's center — light masses (0.6–1.2) with quick damping so the sparks die within about 0.6s, matching the clap rhythm instead of lingering.

.clap__icon { transform: scale(1) rotate(0deg); }
.clap__plus {
  position: absolute;
  opacity: 0;
  transform: translateY(0);
}
.clap.is-clap .clap__icon { animation: clap-bounce $duration $easing; }
.clap.is-clap .clap__plus { animation: clap-plus $duration $easing; }
@keyframes clap-bounce {
  0%   { transform: scale(1) rotate(0deg); }
  40%  { transform: scale(1.3) rotate(-12deg); }
  100% { transform: scale(1) rotate(0deg); }
}
@keyframes clap-plus {
  0%   { opacity: 1; transform: translateY(0); }
  100% { opacity: 0; transform: translateY(-16px); }
}

07Bookmark fold

Anchored at the corner

A small triangle clipped into the ribbon's top-right corner starts scaled to nothing and rotated -90 degrees, anchored at that exact corner through transform-origin rather than the ribbon's center.

Bookmark fold
Open in a new tab

07 · Bookmark fold — Measured area 0.758% · moving frames 17/23Original by GODRICH · SCSS and React ship in the zip · See the password

clip-path 리본transform-origin 모서리rotate+scale

How the fold reads

Checking the box unfolds it back to its resting angle, reading like a corner of paper being pressed down instead of a shape that simply fades in. The instant the fold completes, six diamond paper scraps scatter from that corner — each with its own mass (0.7–1.6), so heavier ones coast farther on inertia before damping (-k/m·v) and gravity pull them to a stop.

.bookmark__fold {
  clip-path: polygon(100% 0, 0 0, 100% 100%);
  transform: scale(0) rotate(-90deg);
  transform-origin: 100% 0;
  opacity: 0;
  transition: opacity $duration $easing, transform $duration $easing;
}
.bookmark__input:checked ~ .bookmark__icon .bookmark__fold {
  opacity: 1;
  transform: scale(1) rotate(0deg);
}

08Thumbs-up bounce

Color returning on check

A grayscale filter keeps the thumb dim until the checkbox behind it is checked, at which point the icon regains full color while rotating from -20 degrees through a 5-degree overshoot back to level.

Thumbs-up bounce
Open in a new tab

08 · Thumbs-up bounce — Measured area 1.192% · moving frames 20/23Original by GODRICH · SCSS and React ship in the zip · See the password

rotate+scale:checked손짓 이징

Why the overshoot matters

That overshoot before it settles is what reads as an actual hand gesture rather than a flat icon spinning into place. Right at that landing moment an impact spark of four mini stars and two dots pops off the icon, with light masses and fast damping (k=4.0) that flash and vanish quickly so the hit feels forceful.

.thumb__icon {
  transform: scale(1) rotate(0deg);
  filter: grayscale(1) opacity(.55);
  transition: filter $duration $easing;
}
.thumb__input:checked ~ .thumb__icon {
  filter: grayscale(0) opacity(1);
  animation: thumb-bounce $duration $easing;
}
@keyframes thumb-bounce {
  0%   { transform: scale(1) rotate(0deg); }
  30%  { transform: scale(1.25) rotate(-20deg); }
  60%  { transform: scale(1.1) rotate(5deg); }
  100% { transform: scale(1) rotate(0deg); }
}

09Emoji reaction

One radio dims the whole row

Four emoji sit in one radio group behind a fieldset, and :has() reaches up from whichever input is checked to dim the whole row before an adjacent-sibling rule grows just that one emoji back up to 1.3 times its size.

Emoji reaction
Open in a new tab

09 · Emoji reaction — Measured area 4.024% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

fieldset/radio:has()scale+opacity

Swapping the pick

Picking a different reaction later just swaps which sibling gets the larger scale, since only one radio in the group can stay checked at a time. The pick also fires five mini hearts upward from that emoji, and because each heart gets a different mass (0.6–1.4), the light ones fall away early while the heavy ones keep coasting — all settled by the same requestAnimationFrame integration.

.react__emoji {
  opacity: .7;
  transform: scale(1);
  transition: opacity $duration $easing, transform $duration $easing;
}
.react:has(.react__input:checked) .react__emoji {
  opacity: .4;
  transform: scale(.9);
}
.react__input:checked + .react__emoji {
  opacity: 1;
  transform: scale(1.3);
}

Where it breaks — the trap

Custom properties do not interpolate

CSS custom properties do not interpolate unless their type is registered. Animating --rating directly between two percentages would jump from one value straight to the next in a single frame rather than tweening between them — exactly the discrete behavior MDN's @property reference documents for unregistered custom properties. Item 05 sidesteps that by keeping --rating only as a calc() input and animating width instead. The rendered measurement backs that choice up: the fill moves across 13 of its 24 captured frames instead of resolving in one jump.

:has() as an enhancement, not a trigger

A second trap sits in item 09 alone, the one piece of this css heart like animation set that leans on a newer selector. Its dimming trick depends on :has() to ask whether any radio in its group is checked — a selector Chrome and Edge 105, Safari 15.4, and Firefox 121 support but nothing older does. On an older engine, every emoji just stays at resting opacity while the adjacent-sibling rule still grows the picked one correctly on its own. Building the picked-emoji scale so it works without :has() first, then layering the group-wide dim on top as a bonus rather than the trigger, keeps the whole picker usable even where that selector never loads. The nine files with that layering already sorted out sit behind 8ayu345c, typed exactly as it sits in this sentence, nothing added and nothing left out.

Accessibility

Reduced motion, item by item

All nine drop straight to their checked end state under prefers-reduced-motion: reduce, though which piece of motion survives depends on how each is built. Items 01, 02, 07, and 08 lose their bounce or unfold transition and just show the finished color and shape. Item 05 swaps its reveal keyframe for a fixed width already set to var(--pct), the same standard-property fallback the trap above leans on. Item 03's scale pop and item 06's plus-one float both switch off, though the number itself still changes, since neither depends on the transition that reduced motion turns off.

Labelling the star group

Item 04's five stars sit inside a fieldset with a visually hidden legend, so a screen reader announces the group's purpose once instead of five unlabeled radios in a row:

<fieldset class="rating">
  <legend class="rating__legend">Rating</legend>
  <input type="radio" id="r5" class="rating__input">
  <label for="r5" class="rating__star" aria-label="5 stars">★</label>
  <input type="radio" id="r4" class="rating__input">
  <label for="r4" class="rating__star" aria-label="4 stars">★</label>
</fieldset>

Swapping in a real button

Any of the checkbox-driven items above reads the same way through a real <button> instead, as long as aria-pressed gets updated by hand on every click since a button carries no built-in checked state of its own:

<button class="heart" type="button" aria-pressed="false" aria-label="Like">

btn.addEventListener("click", function () {
  var on = btn.getAttribute("aria-pressed") === "true";
  btn.setAttribute("aria-pressed", String(!on));
});

More along these lines

More CSS along these same lines lives on the CSS hub, and what this shop actually hands over versus keeps folded inside the zip is spelled out on the about page.

FAQ

Can I copy out just one css heart animation code block, not the whole set?

Yes — each item's markup and SCSS stand on their own, with no shared parent state pulling in the other eight. Item 01's pop and item 04's star fill are the two simplest starting points if a page only needs one reaction, not a full picker.

Do the React versions in the zip track state differently?

The react/ folder swaps every hidden checkbox or radio for a useState boolean or index, read back into the same class names the compiled CSS already expects. Item 05 is the odd one out there too, since it takes a rating prop instead of tracking any state at all, matching its read-only role in the vanilla version.

Does aria-pressed work on all nine, or only the checkbox-based ones?

It fits any of the checkbox items — 01, 02, 03, 07, and 08 — once the checkbox is swapped for a button, since each of those already toggles a single on-or-off state. Item 06 is already a plain <button>, but its repeat-and-count tap doesn't toggle on or off, so aria-pressed doesn't describe it either way. Items 04 and 09 pick one option out of several, so a radiogroup role or the fieldset shown above fits their behavior better than a row of separately pressed buttons.

Enter the archive password

The password is inside this article. You will find it as you read.