Trello Checklist

Recreation of Trello's staggered checkbox animation.

Demo

Recreation of Trello's staggered checkbox animation that runs after the last item in a checklist is completed.

Code

import { stagger, useAnimate } from "framer-motion";
import { useState } from "react";
import { ListBulletIcon } from "@heroicons/react/20/solid";

function Demo() {
  let [items, setItems] = useState([
    { id: "1", text: "One", checked: true },
    { id: "2", text: "Two", checked: true },
    { id: "3", text: "Three", checked: true },
    { id: "4", text: "Four", checked: false },
    { id: "5", text: "Five", checked: true },
    { id: "6", text: "Six", checked: true },
    { id: "7", text: "Seven", checked: true },
  ]);

  /*
    🟢 This hook gives us an `animate()` function that's scoped to a
    specific element and its children. The `animate()` function
    is perfect for imperatively kicking off animations, for example
    in response to an event – exactly what we're doing in this demo.
  */
  let [ref, animate] = useAnimate();

  function handleChange(id: string) {
    let newItems = items.map((item) => ({
      ...item,
      checked: item.id === id ? !item.checked : item.checked,
    }));

    setItems(newItems);

    // 🟢 If every item has been checked...
    if (newItems.every((item) => item.checked)) {
      let lastCompletedItem = items.findIndex((item) => !item.checked);
      let random = Math.random();

      if (random < 1 / 3) {
        /*
          🟢 ...animate each input in the list. The animation uses an array
          of keyframes to scale each input from 100% to 125% then back to 100%
          in sequence. The `delay` option is used along with the `stagger`
          helper to stagger the individual animimations. Stagger accepts
          a `from` option to use as a starting point, which we set to
          the index of the last completed item.
        */
        animate(
          "input",
          { scale: [1, 1.25, 1] },
          {
            duration: 0.35,
            delay: stagger(0.075, { from: lastCompletedItem }),
          }
        );
      } else if (random < 2 / 3) {
        /*
          🟢 The first animation is a "bounce" effect. This second one is
          a "shimmy" effect, achieved by keyframing the `x` prop.
        */
        animate(
          "input",
          { x: [0, 2, -2, 0] },
          {
            duration: 0.4,
            delay: stagger(0.1, { from: lastCompletedItem }),
          }
        );
      } else {
        /*
          🟢 This final effect is a "shake", achieved by keyframing the `rotate` prop.
          One of the three effects are randomly selected each time the list is completed.
        */
        animate(
          "input",
          { rotate: [0, 10, -10, 0] },
          {
            duration: 0.5,
            delay: stagger(0.1, { from: lastCompletedItem }),
          }
        );
      }
    }
  }

  return (
    <div className="flex min-h-full flex-col items-center justify-center">
      <div className="flex w-full max-w-sm flex-col rounded bg-gray-100 px-3 py-4 shadow-xl">
        <p className="ml-2 flex items-center text-lg font-semibold text-gray-700">
          <ListBulletIcon className="mr-3 h-5 w-5" />
          Checklist
        </p>

        {/*
           🟢 Attach the ref from `useAnimate()` to scope the animate() function to this subtree.
        */}
        <div ref={ref} className="mt-4">
          {items.map((item) => (
            <label
              key={item.id}
              className={`group flex w-full cursor-pointer select-none items-center rounded p-2 text-sm font-medium transition-colors duration-300 checked:text-gray-300 hover:bg-gray-200 ${
                item.checked
                  ? "text-gray-400 line-through"
                  : "text-gray-800"
              }`}
            >
              <input
                onChange={() => handleChange(item.id)}
                checked={item.checked}
                type="checkbox"
                className="mr-4 h-4 w-4 rounded-sm border-2 border-gray-300 text-sky-600 transition-colors duration-300 focus:ring-0 focus:ring-offset-0 focus-visible:ring-2 focus-visible:ring-sky-600/50 focus-visible:ring-offset-2 focus-visible:ring-offset-gray-100 group-active:border-sky-600 group-active:checked:text-sky-600/25"
              />
              {item.text}
            </label>
          ))}
        </div>
      </div>
    </div>
  );
}

Libraries used

Get our latest in your inbox.

Join our newsletter to hear about Sam and Ryan's newest blog posts, code recipes, and videos.