Skip to content

Animated list

Enter, leave and move animations with CSS and a few signals.

New items fade in on mount. A removed item first gets a leaving state that plays its exit transition, then leaves the array. Shuffling measures every row before and after the move and animates the difference. With reduced motion on, everything happens at once.

Preview

Open the page on its own

Code

Save it as an .html file and open it in a browser. It loads Signy and Iris CSS from the CDN, so there is nothing to install or build.

Show the full page
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <meta name="color-scheme" content="light dark">
    <title>Animated list - Signy example</title>
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/iriscss@1/dist/iris.min.css">
    <style>
      .animated {
        padding: 0;
        list-style: none;
      }

      .animated li {
        display: flex;
        align-items: center;
        justify-content: space-between;
        padding: 0.5rem 0.75rem;
        margin-bottom: 0.5rem;
        border-radius: var(--i-border-radius);
        background: var(--i-color-subtle-border);
        opacity: 0;
        transform: translateX(-1rem);
        transition: opacity 0.3s, transform 0.3s;
      }

      .animated li button {
        margin: 0;
      }

      .animated li.entered {
        opacity: 1;
        transform: none;
      }

      .animated li.leaving {
        opacity: 0;
        transform: translateX(1rem);
      }

      @media (prefers-reduced-motion: reduce) {
        .animated li {
          transition: none;
        }
      }
    </style>
    <script src="https://cdn.jsdelivr.net/npm/signy@1/dist/signy.global.min.js"></script>
  </head>
  <body>
    <main class="container" id="app"></main>

    <script>
      const { Component, Signal, application, html, For, flushAll } = Signy;

      const FRUITS = ["Apple", "Banana", "Cherry", "Damson", "Elderberry", "Fig", "Grape", "Kiwi", "Lemon", "Mango"];
      const reduced = () => matchMedia("(prefers-reduced-motion: reduce)").matches;
      const DURATION = 300;
      let nextId = 1;

      const item = (name) => ({ id: nextId++, name, leaving: new Signal(false) });

      class AnimatedList extends Component {
        items = new Signal(FRUITS.slice(0, 5).map(item));
        rows = new Map();

        // A new row starts hidden and gets .entered on the next frame, so its transition plays.
        // The first rows get a growing delay, which staggers the first render.
        enter(id, element, index) {
          this.rows.set(id, element);
          if (index !== null) element.style.transitionDelay = `${index * 60}ms`;
          requestAnimationFrame(() => requestAnimationFrame(() => {
            element.classList.add("entered");
            if (index !== null) setTimeout(() => (element.style.transitionDelay = ""), DURATION + index * 60);
          }));
        }

        // Prefer a fruit not on screen yet, so every Remove button has its own name
        add() {
          const shown = new Set(this.items.peek().map((entry) => entry.name));
          const free = FRUITS.filter((name) => !shown.has(name));
          const pool = free.length ? free : FRUITS;
          const name = pool[Math.floor(Math.random() * pool.length)];
          this.items.set((list) => [...list, item(name)]);
        }

        // Play the exit first, then take the item out of the list
        remove(entry) {
          entry.leaving.value = true;
          setTimeout(() => {
            this.items.set((list) => list.filter((other) => other.id !== entry.id));
            this.rows.delete(entry.id);
          }, reduced() ? 0 : DURATION);
        }

        // FLIP: note where each row is, reorder, then animate from the old place to the new one
        shuffle() {
          const before = new Map([...this.rows].map(([id, row]) => [id, row.getBoundingClientRect().top]));
          this.items.set((list) => [...list].sort(() => Math.random() - 0.5));
          flushAll();
          if (reduced()) return;
          for (const [id, row] of this.rows) {
            const delta = before.get(id) - row.getBoundingClientRect().top;
            if (!delta) continue;
            row.style.transition = "none";
            row.style.transform = `translateY(${delta}px)`;
            row.getBoundingClientRect();
            row.style.transition = "";
            row.style.transform = "";
          }
        }

        render() {
          const first = new Set(this.items.peek().map((entry) => entry.id));
          return html`
            <h1>Animated list</h1>
            <div class="cluster">
              <button class="primary" onClick=${() => this.add()}>Add a fruit</button>
              <button class="secondary outlined" onClick=${() => this.shuffle()}>Shuffle</button>
            </div>
            <ul class="animated">
              ${For({
                each: () => this.items.value,
                keyBy: "id",
                children: (entry, index) => html`
                  <li class=${() => (entry.leaving.value ? "entered leaving" : "")}
                    ref=${(el) => this.enter(entry.id, el, first.has(entry.id) ? index() : null)}>
                    ${entry.name}
                    <button class="small outlined" aria-label=${`Remove ${entry.name}`}
                      disabled=${() => entry.leaving.value} onClick=${() => this.remove(entry)}>Remove</button>
                  </li>
                `,
              })}
            </ul>
          `;
        }
      }

      application(AnimatedList).mount("#app");
    </script>
  </body>
</html>

What it uses

  • For with keyBy, so rows keep their DOM nodes when they move
  • Refs to measure rows
  • onMount() for the enter animation