DEV Community

Cover image for How to add a like animation in React (and play it in reverse)
Roman
Roman

Posted on Edited on AI-assisted

How to add a like animation in React (and play it in reverse)

Updated Oct 20: added what to do when the post is already liked on load, and how to respect Reduce Motion. Thanks to Skillselion in the comments for catching it.

A static like icon next to an animated Lottie like

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
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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: false and loop: false keep it parked on the first frame until someone taps.
  • On like, we set the direction to "forward" and call play(). The heart fills and bounces.
  • On unlike, we set the direction to "reverse" and call play(). 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.

When the post is already liked

The demo above starts empty. In a real feed, some posts are already liked when the page loads. With autoplay: false, the player parks on the first frame, so the heart shows empty while the state says "liked". The picture and the state disagree until someone taps.

The fix: take the starting state from props, and once the animation has loaded, jump straight to the last frame without playing.

The same jump also covers people who turned on Reduce Motion. lottie-web doesn't check that setting on its own, so we check it ourselves and swap frames instead of playing. The state still changes visibly, just without the bounce.

import { useEffect, useState } from "react";
import { useLottie } from "lottie-react";
import likeAnimation from "./like.json";

const prefersReducedMotion = () =>
  window.matchMedia("(prefers-reduced-motion: reduce)").matches;

export function LikeButton({ initiallyLiked = false }) {
  const [liked, setLiked] = useState(initiallyLiked);
  const lottie = useLottie({ src: likeAnimation, autoplay: false, loop: false });

  // Show the right picture on load: last frame if already liked
  useEffect(() => {
    if (!lottie.animationLoaded) return;
    const lastFrame = lottie.getDuration(true) - 1;
    lottie.goToAndStop(liked ? lastFrame : 0, true);
  }, [lottie.animationLoaded]);

  const toggle = () => {
    const next = !liked;
    setLiked(next);

    if (prefersReducedMotion()) {
      lottie.goToAndStop(next ? lottie.getDuration(true) - 1 : 0, true);
      return;
    }

    lottie.setDirection(next ? "forward" : "reverse");
    lottie.play();
  };

  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>
  );
}
Enter fullscreen mode Exit fullscreen mode

Use it as <LikeButton initiallyLiked={post.likedByMe} />. One nice side effect Skillselion pointed out: because the unlike plays the same motion in reverse, an ease-out entrance becomes an ease-in exit, which is exactly what exits usually want.

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;
}
Enter fullscreen mode Exit fullscreen mode

Then, once at the top of your file (outside the component):

const pinkLike = recolor(likeAnimation, { "#FF4A1C": "#E11D48" });
Enter fullscreen mode Exit fullscreen mode

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 (2)

Collapse
 
skillselion profile image
Skillselion •

One case the demo skips: a post that is already liked when the page loads. With autoplay off, the player parks on frame 0, so the heart renders empty and the state and the picture disagree until someone taps. Seeding the state from props and jumping to the last frame once the animation has loaded (goToAndStop in lottie-web terms) fixes it. That same jump covers reduced motion. As far as I know, lottie-web does not check prefers-reduced-motion by itself, so a matchMedia check that swaps frames instead of playing keeps the state change visible without the bounce. Nice side effect of reversing, by the way: an ease-out entrance turns into an ease-in exit, which is what exits usually want.

Collapse
 
romanherenow profile image
Roman •

Great catch, thanks. That’s exactly the case I skipped: a liked post rendering empty until someone taps is worse than no animation at all. goToAndStop on the last frame once the animation loads is the right fix, and I’ll add it to the lesson along with the matchMedia check, since you’re right that lottie-web won’t respect reduced motion on its own. And yes, the reverse trick giving you an ease-in exit for free is my favourite part of it.