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
- 01 Heart pop
- 02 Particle burst
- 03 Like count sync
- 04 Star fill
- 05 Half-star gradient display
- 06 Clap count
- 07 Bookmark fold
- 08 Thumbs-up bounce
- 09 Emoji reaction
The order below 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
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. 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 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. 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
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. 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 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. The one radio actually checked gets an extra scale bounce through the adjacent-sibling selector, confirming exactly which star the tap landed on.
.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
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. 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.
.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
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. Sharing one class between both keeps the plus-one from hanging around after the icon's bounce has already finished.
.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
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. 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.
.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
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. That overshoot before it settles is what reads as an actual hand gesture rather than a flat icon spinning into place.
.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
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. 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.
.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
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.
A second trap sits in item 09 alone. 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
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.
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>
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 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 of these nine, 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.