Animated List

Use Framer Motion to add mount and unmount animations to items in a list.

Simple list

import { motion, AnimatePresence } from "framer-motion";
import { useState } from "react";

function SimpleList() {
  let [todos, setTodos] = useState([
    { id: 3, text: "Todo 3" },
    { id: 2, text: "Todo 2" },
    { id: 1, text: "Todo 1" },
  ]);

  function addTodo() {
    let newId = Math.max(...todos.map((t) => t.id)) + 1;
    setTodos([{ id: newId, text: `Todo ${newId}` }, ...todos]);
  }

  function removeTodo(todo) {
    setTodos((todos) => todos.filter((t) => t.id !== todo.id));
  }

  return (
    <div>
      <button onClick={addTodo}>Add todo</button>
      <ul>
        <AnimatePresence initial={false}>
          {todos.map((todo) => (
            <motion.li
              key={todo.id}
              initial={{ height: 0 }}
              animate={{ height: "auto" }}
              exit={{ height: 0 }}
              style={{ overflow: "hidden" }}
            >
              <button onClick={() => removeTodo(todo)}>{todo.text}</button>
            </motion.li>
          ))}
        </AnimatePresence>
      </ul>
    </div>
  );
}

Y correction for consecutive selected items

The demo above applies a correction for consecutive selected items that unmount. Without the correction, the text from consecutive items move at different rates while their parent's height animates to 0.

To correct this, each unmounting item counts the number of selected items that immediately follow it, and applies an additional y transform of the item height (currently hard-coded to 53px) times that count. This gives the appearance that the group of selected items is being pushed up under the items above it. A z-index correction is also neeeded for earlier items to stay on top of later ones during the animation.

function ListWithCorrection() {
  // ...

  return (
    <ul>
      <AnimatePresence initial={false}>
        {todos.map((todo) => (
          <motion.div
            key={todo.id}
            initial={{ height: 0 }}
            animate={{ height: "auto" }}
            exit={{
              height: 0,
              y: -53 * countSelectedTodosAfter(todos, selectedTodos, todo),
              zIndex: groupSelectedTodos(todos, selectedTodos)
                .reverse()
                .findIndex((group) => group.includes(todo)),
            }}
            style={{ overflow: "hidden", zIndex: 1000 }}
          >
            <button onClick={() => removeTodo(todo)}>{todo.text}</button>
          </motion.div>
        ))}
      </AnimatePresence>
    </ul>
  );
}

function countSelectedTodosAfter(
  todos: Todo[],
  selectedTodos: Todo[],
  todo: Todo,
) {
  const startIndex = todos.indexOf(todo);

  if (startIndex === -1 || !selectedTodos.includes(todo)) {
    return 0;
  }

  let consecutiveCount = 0;

  for (let i = startIndex + 1; i < todos.length; i++) {
    if (selectedTodos.includes(todos[i])) {
      consecutiveCount++;
    } else {
      break;
    }
  }

  return consecutiveCount;
}

function groupSelectedTodos(todos: Todo[], selectedTodos: Todo[]) {
  const todoGroups = [];
  let currentGroup = [];

  for (let i = 0; i < todos.length; i++) {
    const todo = todos[i];

    if (selectedTodos.includes(todo)) {
      currentGroup.push(todo);
    } else if (currentGroup.length > 0) {
      // If we encounter a non-selected message and there is an active group,
      // push the current group to the result and reset it.
      todoGroups.push(currentGroup);
      currentGroup = [];
    }
  }

  // Check if there's a group remaining after the loop.
  if (currentGroup.length > 0) {
    todoGroups.push(currentGroup);
  }

  return todoGroups;
}

View the demo source on GitHub to see the full code.

Border radius styling for consecutive selected items

Finally, the countSelectedTodosAfter() function and a new countSelectedTodosBefore() function are both used to tweak the border radius for consecutive selected items. The first item in a group has top border radius, the last has bottom, and all others have none. There's also a light 1px bottom border between selected items to act as a divider.

function ListWithCorrectionAndStyling() {
  // ...

  return (
    <ul>
      <AnimatePresence initial={false}>
        {todos.map((todo) => (
          <motion.div
            initial={{ height: 0 }}
            animate={{ height: "auto" }}
            exit={{
              height: 0,
              y: -53 * countSelectedTodosAfter(todos, selectedTodos, todo),
              zIndex: groupSelectedTodos(todos, selectedTodos)
                .reverse()
                .findIndex((group) => group.includes(todo)),
            }}
            key={todo.id}
            style={{ overflow: "hidden", zIndex: 1000 }}
          >
            <button
              onClick={() => toggleTodo(todo)}
              className={`border-b-[1px]
                ${
                  selectedTodos.includes(todo)
                    ? "bg-blue-500"
                    : "hover:bg-gray-500/50"
                }
                ${
                  countSelectedTodosAfter(todos, selectedTodos, todo) === 0
                    ? "rounded-b border-transparent"
                    : "border-white/10"
                }
                ${
                  countSelectedTodosBefore(todos, selectedTodos, todo) === 0
                    ? "rounded-t"
                    : ""
                }
              `}
            >
              <p>{todo.text}</p>
            </button>
          </motion.div>
        ))}
      </AnimatePresence>
    </ul>
  );
}

function countSelectedTodosBefore(
  todos: Todo[],
  selectedTodos: Todo[],
  todo: Todo
) {
  const endIndex = todos.indexOf(todo);

  if (endIndex === -1 || !selectedTodos.includes(todo)) {
    return 0;
  }

  let consecutiveCount = 0;

  for (let i = endIndex - 1; i >= 0; i--) {
    if (selectedTodos.includes(todos[i])) {
      consecutiveCount++;
    } else {
      break;
    }
  }

  return consecutiveCount;
}

View the demo source on GitHub to see the full code.

Links