When you come across something you like in an app, a great article, a funny video, a photo that catches your eye, you want to respond, so you hit the like button. If the heart just switches from an outline to a solid icon, it's kind of meh. Okay, I clicked it, but did anything really happen? If the heart fills up and bounces a little, it feels like your tap actually meant something.
I'm not a coder, I'm a motion designer. I create animations like this in After Effects, and I was curious to see how they'd work in a real interface, so I built this button from scratch. This is what I figured out along the way.
Building that bounce in CSS takes keyframes, a scale curve, a color transition and a second set of rules for un-liking. With Lottie, you take the motion a designer made in After Effects as a small JSON file, and React only has to tell it when to play and in which direction.
Here's the whole thing, step by step.
What you need
- A React app (React 18.2 or newer)
- A like animation in Lottie format. You can use mine: like.json for light-on-dark UIs, or like-light.json for dark-on-light. It's 3.5 KB and free to use in your projects.
1. Install lottie-react
npm i lottie-react
This installs lottie-react v3, which brings lottie-web (the player) with it.
2. The component
import { useState } from "react";
import { useLottie } from "lottie-react";
import likeAnimation from "./like.json";
export function LikeButton() {
const [liked, setLiked] = useState(false);
const lottie = useLottie({ src: likeAnimation, autoplay: false, loop: false });
const toggle = () => {
lottie.setDirection(liked ? "reverse" : "forward");
lottie.play();
setLiked(!liked);
};
return (
<button
onClick={toggle}
aria-pressed={liked}
aria-label={liked ? "Unlike" : "Like"}
style={{ background: "none", border: 0, padding: 0, cursor: "pointer" }}
>
<div ref={lottie.setDisplayRef} style={{ width: 48, height: 48 }} />
</button>
);
}
That's it. Drop <LikeButton /> anywhere.
3. How it works
The animation goes from "not liked" on its first frame to "liked" on its last frame. So:
-
autoplay: falseandloop: falsekeep it parked on the first frame until someone taps. - On like, we set the direction to
"forward"and callplay(). The heart fills and bounces. - On unlike, we set the direction to
"reverse"and callplay(). The same motion runs backwards and the heart empties.
The logic is three lines: pick a direction, play, flip the state. Everything else is markup.
aria-pressed tells screen readers the button is a toggle, and the label changes with it, so the animation isn't the only way to know the state.
The easy mistake: restarting instead of reversing
The tempting way to un-like is to jump to the start and play a separate "unlike" animation, or to reset the animation to frame 0. It looks fine when you tap slowly. Now tap twice quickly: the second tap lands while the heart is still bouncing, the animation snaps to its start, and the heart visibly jumps.
Playing in reverse doesn't have this problem. If you tap while the heart is still moving, in either direction, setDirection turns the animation around from exactly where it is, so it always moves smoothly. Try tapping fast in the demo: it never jumps.
One more thing if you're copying from older tutorials. Many of them are written for lottie-react v2, which used animationData and setDirection(-1). In v3 the prop is src and the direction is a word, "forward" or "reverse". If your copied snippet throws or does nothing, that's usually why.
Make it yours: change the color
The heart is orange in my file. Lottie stores colors as numbers inside the JSON, so you can swap them before you pass the animation in:
type Hex = `#${string}`;
const toRgb = (hex: Hex) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255);
export function recolor<T>(animation: T, colors: Record<Hex, Hex>): T {
const swaps = Object.entries(colors).map(([from, to]) => [toRgb(from as Hex), toRgb(to as Hex)]);
const copy = structuredClone(animation);
const walk = (node: any): void => {
if (Array.isArray(node)) return node.forEach(walk);
if (!node || typeof node !== "object") return;
const k = node.c?.k;
if ((node.ty === "fl" || node.ty === "st") && Array.isArray(k) && typeof k[0] === "number") {
const hit = swaps.find(([from]) => from.every((v, i) => Math.abs(v - k[i]) < 0.01));
if (hit) node.c.k = [...hit[1], k[3] ?? 1];
}
Object.values(node).forEach(walk);
};
walk(copy);
return copy;
}
Then, once at the top of your file (outside the component):
const pinkLike = recolor(likeAnimation, { "#FF4A1C": "#E11D48" });
and pass src: pinkLike. The animation uses only two colors, the line (#F2F1ED, or #121212 in the light version) and the accent (#FF4A1C), so two entries are enough to match any brand.
Wrapping up
A like that lands instead of flipping is a small thing, but it's the kind of small thing people feel without being able to name it. With lottie-react it costs you a 3.5 KB file and three lines of logic.
The animation in this post is from Zeltrium, my pack of Lottie UI micro-interactions. Get 5 more free at zeltrium.com.

Top comments (0)