AutoAnimate Todo List — FLIP Technique Explained
AutoAnimate Todo List · Dashboards · Plain HTML & CSS · Live preview
Copy & paste
Source Code
<div class="aat-stage">
<div class="aat-head">
<span class="aat-tag">@formkit/auto-animate · one call, zero orchestration</span>
<h2>Today's Tasks</h2>
</div>
<form class="aat-form" id="aatForm">
<input class="aat-input" id="aatInput" type="text" placeholder="Add a task…" autocomplete="off" />
<button class="aat-add" type="submit">Add</button>
</form>
<ul class="aat-list" id="aatList">
<li class="aat-item" data-id="1"><label><input type="checkbox" /><span>Write the quarterly report</span></label><button class="aat-del" aria-label="Delete">✕</button></li>
<li class="aat-item" data-id="2"><label><input type="checkbox" /><span>Review pull requests</span></label><button class="aat-del" aria-label="Delete">✕</button></li>
<li class="aat-item" data-id="3"><label><input type="checkbox" /><span>Book flights for offsite</span></label><button class="aat-del" aria-label="Delete">✕</button></li>
<li class="aat-item is-done" data-id="4"><label><input type="checkbox" checked /><span>Reply to design feedback</span></label><button class="aat-del" aria-label="Delete">✕</button></li>
</ul>
</div>
<script type="module">
import autoAnimate from 'https://cdn.jsdelivr.net/npm/@formkit/[email protected]/index.mjs';
var list = document.getElementById('aatList');
// The entire animation system is this one call. autoAnimate watches the
// parent element with a MutationObserver; whenever a direct child is added,
// removed, or reordered by ANY future code — this file's, or code you add
// later — it automatically animates the change. No callback, no per-action
// wiring, ever needed again below this line.
autoAnimate(list);
var nextId = 5;
document.getElementById('aatForm').addEventListener('submit', function (e) {
e.preventDefault();
var input = document.getElementById('aatInput');
var text = input.value.trim();
if (!text) return;
var li = document.createElement('li');
li.className = 'aat-item';
li.dataset.id = String(nextId++);
li.innerHTML = '<label><input type="checkbox" /><span></span></label><button class="aat-del" aria-label="Delete">✕</button>';
li.querySelector('span').textContent = text;
list.appendChild(li);
input.value = '';
});
list.addEventListener('click', function (e) {
if (e.target.matches('.aat-del')) {
e.target.closest('.aat-item').remove();
return;
}
if (e.target.matches('input[type="checkbox"]')) {
var item = e.target.closest('.aat-item');
item.classList.toggle('is-done', e.target.checked);
// Moving the completed item to the end of the list is a plain DOM
// mutation (appendChild on an existing node moves it) — autoAnimate
// treats this reorder exactly like an add or remove and animates it.
if (e.target.checked) list.appendChild(item);
}
});
</script>*{box-sizing:border-box;margin:0;padding:0}
body{font-family:system-ui,-apple-system,sans-serif;background:radial-gradient(120% 100% at 50% 0%,#161c2b,#0a0d15);color:#fff;min-height:100vh;display:flex;align-items:center;justify-content:center;padding:24px}
.aat-stage{width:min(420px,94vw);display:flex;flex-direction:column;gap:18px}
.aat-head{text-align:center}
.aat-tag{display:inline-block;font-size:11px;font-weight:700;letter-spacing:.14em;text-transform:uppercase;color:#fb923c;background:rgba(251,146,60,.12);border:1px solid rgba(251,146,60,.3);padding:5px 12px;border-radius:99px;margin-bottom:12px}
.aat-head h2{font-size:clamp(22px,5vw,28px);font-weight:800;letter-spacing:-.02em}
.aat-form{display:flex;gap:8px}
.aat-input{flex:1;padding:11px 14px;border-radius:10px;border:1px solid rgba(255,255,255,.14);background:rgba(255,255,255,.05);color:#fff;font:14px system-ui}
.aat-input:focus{outline:none;border-color:#fb923c}
.aat-add{padding:11px 18px;border-radius:10px;border:none;background:#fb923c;color:#1a1000;font:700 13.5px system-ui;cursor:pointer}
.aat-list{list-style:none;display:flex;flex-direction:column;gap:8px}
.aat-item{display:flex;align-items:center;justify-content:space-between;gap:10px;background:rgba(255,255,255,.04);border:1px solid rgba(255,255,255,.09);border-radius:12px;padding:12px 14px}
.aat-item label{display:flex;align-items:center;gap:10px;flex:1;cursor:pointer}
.aat-item input[type="checkbox"]{width:17px;height:17px;accent-color:#fb923c}
.aat-item span{font-size:13.5px}
.aat-item.is-done{opacity:.55}
.aat-item.is-done span{text-decoration:line-through}
.aat-del{background:none;border:none;color:#6b7280;font-size:14px;cursor:pointer;padding:4px}
.aat-del:hover{color:#f87171}Extend with AI
Take This Snippet Further With AI
The AI prompt for this one is for Patreon supporters ($3 a month, 7 days free), along with a site with no ads. Supporters enter the passcode once and it is remembered in this browser.
What's included
Features
About this UI Snippet
AutoAnimate Todo List — What FLIP Is and Why One Call Covers Every Future Mutation

Most "animate my list" solutions require wrapping every mutation — every add, remove, and reorder gets its own explicit before/after animation call. @formkit/auto-animate takes a fundamentally different approach: you call autoAnimate(parentElement) once, and it transparently animates *any* direct-child DOM mutation that happens afterward, forever, without you writing a single animation call anywhere else. This snippet's entire animation code is one line: autoAnimate(list).
The FLIP technique, in plain terms
FLIP stands for First, Last, Invert, Play, and it's the standard technique for animating layout changes that can't be expressed as a simple CSS transition (like a list item reordering, which changes an element's actual position in the DOM/layout, not just a style property). The steps:
1. First — record every affected element's current bounding box (position, size) before the mutation happens. 2. Last — let the mutation actually happen (the DOM add/remove/reorder), then immediately record each element's *new* bounding box. 3. Invert — for each element, compute the delta between First and Last, and apply an inverse CSS transform so the element is instantly repositioned to *look* exactly like it did before the mutation, even though it's now actually in its new DOM position. 4. Play — remove the inverse transform with a CSS transition, letting the element animate smoothly from its old apparent position to its real new one.
The trick is that steps 1-3 all happen synchronously with no visible flash, so the only thing the user perceives is step 4: a smooth animated transition from old position to new. autoAnimate implements exactly this loop internally.
Why one autoAnimate() call is enough for arbitrary future mutations
js
autoAnimate(list);
This one call attaches a MutationObserver to list. A MutationObserver is a native browser API that fires a callback whenever a DOM subtree changes — child added, removed, reordered, or an attribute/text changed. Because autoAnimate hooks the observer once at the parent level rather than instrumenting individual operations, it doesn't matter *how* a child gets added, removed, or moved later — whether it's this snippet's appendChild, a completely different piece of code added six months from now, or a totally different framework's rendering pass touching the same DOM node. Any of those trigger the same observer callback, which runs the same FLIP measurement-and-transform sequence automatically. You never call an animation function directly; you only ever mutate the DOM normally, and autoAnimate intercepts the *effect* of that mutation.
Completing a task is a reorder, not a special case
js
if (e.target.checked) list.appendChild(item);
Calling appendChild on a node that's already in the document doesn't clone it — it *moves* it to the end of its new parent (which, since it's the same parent here, is just a reorder to the last position). This is a completely ordinary DOM operation with zero autoAnimate-specific code, and autoAnimate animates it exactly the same way it animates an add or a delete: it's just another mutation the observer sees.
Reusing it
Anything you'd build with plain DOM mutation — a Kanban column, a leaderboard whose rows re-sort, a notification tray — gets List-animation for free the moment its container is wrapped in autoAnimate(). There's no API to learn beyond that one function call.
Step by step
How to Use
- 1No CDN script tag needed@formkit/auto-animate ships no UMD/global build — the demo imports it directly as an ES module inside a <script type="module"> tag instead.
- 2Paste HTML, CSS, and JSA todo list renders with a few starter tasks, one already checked off.
- 3Add a taskType into the input and submit — the new item animates in at the bottom.
- 4Check a task offIt strikes through, dims, and smoothly slides to the end of the list — a plain appendChild move.
- 5Delete a taskThe remaining items smoothly slide up to fill the gap, with no manual animation code for that either.
- 6Read the one setup lineautoAnimate(list) is the entire animation system — every future mutation is covered automatically.
Real-world uses
Common Use Cases
autoAnimate(list) call, with no explicit animation code around each mutation.Got questions?
Frequently Asked Questions
First, Last, Invert, Play. First and Last record an element's bounding box before and after a DOM mutation. Invert applies a CSS transform that makes the element instantly LOOK like it's still in its old position even though it has actually moved. Play removes that transform with a CSS transition, so the element visibly animates from the old apparent position to its real new one.
autoAnimate attaches a native MutationObserver to the parent element, which is a browser API that fires whenever the DOM subtree changes — regardless of what caused the change. Because the observer watches the PARENT, not specific operations, any code that later adds, removes, or reorders a direct child of that parent triggers the same observer callback and gets the same FLIP animation, with no additional setup.
Calling appendChild on a DOM node that already exists in the document does not clone it — it relocates the existing node to the end of its parent. Since autoAnimate is already observing that parent for any child mutation, this ordinary move is automatically detected and animated with the exact same FLIP sequence as an add or delete, with zero autoAnimate-specific API used.
Yes — since it operates on the actual DOM via a MutationObserver rather than intercepting your code's API calls, it doesn't care whether the mutations come from vanilla DOM methods, React's reconciler, or Vue's renderer. You still only need to call autoAnimate() once on the parent element (typically via a ref in React or a directive in Vue).
Your plain DOM calls (appendChild, remove(), insertBefore, etc.) are what CHANGE the DOM; autoAnimate's MutationObserver callback is what OBSERVES that change and runs the FLIP sequence in response. You never call an animation function yourself — you just mutate the DOM as you normally would, and the observer does the rest.
No — autoAnimate applies its own inline transform and transition styles temporarily during the FLIP sequence and removes them afterward. You don't need to define enter/exit CSS classes the way you would with a typical transition-group style animation library.
@formkit/auto-animate publishes no UMD/IIFE build on npm — only an ES module (index.mjs) and its minified ESM equivalent, both ending in a real export statement, which throws a syntax error if loaded as a classic script. The fix is to import it directly, which requires the importing <script> tag itself to be type="module" — that's why this demo's whole script lives inline in the HTML instead of a separate CDN <script src> plus a global function.