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
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>