Chapter 40: Vue Animations with v-for
Animations with v-for → using <TransitionGroup>
This is the exact tool that makes todo lists, product grids, search results, chat messages, kanban cards, notification lists, etc. feel alive, smooth and professional instead of just appearing/disappearing abruptly.
When items are added, removed or reordered in a v-for loop, Vue can animate each individual item — and even animate their position changes when the list order changes (that’s the real magic).
Why <TransitionGroup> Instead of <Transition>?
- <Transition> → works with single elements that appear/disappear (v-if / v-show)
- <TransitionGroup> → specially made for multiple elements in a list (v-for)
Key differences:
| Feature | <Transition> | <TransitionGroup> |
|---|---|---|
| Works with | single element | many elements (v-for) |
| Animates position changes? | No | Yes — move animation when order changes |
| Renders a wrapper tag? | No (unless you set tag) | Yes — defaults to <span>, can set tag=”ul” |
| Required :key? | Recommended | Mandatory (unique & stable) |
| Common use-cases | modals, alerts, tabs content | todo lists, cards, tables rows, messages |
Real, Complete Example – Animated Todo List (2026 Style)
|
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 |
<template> <div class="todo-app"> <h1>Animated Todo List</h1> <div class="input-row"> <input v-model.trim="newTask" placeholder="What needs to be done?" @keyup.enter="addTask" /> <button @click="addTask">Add</button> </div> <!-- This is the star of the show --> <TransitionGroup name="todo" tag="ul"> <li v-for="todo in todos" :key="todo.id" class="todo-item" > <input type="checkbox" :checked="todo.done" @change="toggleDone(todo.id)" /> <span :class="{ done: todo.done }">{{ todo.text }}</span> <button class="delete-btn" @click="removeTodo(todo.id)"> × </button> </li> </TransitionGroup> <div class="stats"> <p>{{ remaining }} of {{ todos.length }} remaining</p> <button v-if="todos.some(t => t.done)" @click="clearCompleted"> Clear Completed </button> </div> </div> </template> <script setup lang="ts"> import { ref, computed } from 'vue' interface Todo { id: number text: string done: boolean } const newTask = ref('') const todos = ref<Todo[]>([ { id: 1, text: 'Learn TransitionGroup', done: true }, { id: 2, text: 'Animate list items', done: false }, { id: 3, text: 'Make it feel smooth', done: false } ]) const remaining = computed(() => todos.value.filter(t => !t.done).length) function addTask() { if (!newTask.value.trim()) return todos.value.push({ id: Date.now(), text: newTask.value.trim(), done: false }) newTask.value = '' } function toggleDone(id: number) { const todo = todos.value.find(t => t.id === id) if (todo) todo.done = !todo.done } function removeTodo(id: number) { todos.value = todos.value.filter(t => t.id !== id) } function clearCompleted() { todos.value = todos.value.filter(t => !t.done) } </script> <style scoped> .todo-app { max-width: 600px; margin: 3rem auto; padding: 2rem; background: white; border-radius: 12px; box-shadow: 0 10px 30px rgba(0,0,0,0.08); } .input-row { display: flex; gap: 0.8rem; margin-bottom: 2rem; } input { flex: 1; padding: 0.9rem; border: 1px solid #d1d5db; border-radius: 6px; font-size: 1.05rem; } button { padding: 0.9rem 1.6rem; background: #3b82f6; color: white; border: none; border-radius: 6px; cursor: pointer; font-weight: 500; } .todo-item { display: flex; align-items: center; padding: 0.9rem 1.2rem; background: #f8fafc; border-radius: 8px; margin-bottom: 0.6rem; box-shadow: 0 1px 4px rgba(0,0,0,0.06); transition: transform 0.2s; } .todo-item:hover { transform: translateX(4px); } .done { text-decoration: line-through; color: #9ca3af; opacity: 0.75; } .delete-btn { background: none; border: none; color: #ef4444; font-size: 1.5rem; cursor: pointer; padding: 0 0.6rem; opacity: 0.7; } .delete-btn:hover { opacity: 1; } .stats { margin-top: 1.5rem; display: flex; justify-content: space-between; align-items: center; color: #4b5563; } /* ──────────────────────────────────────── TransitionGroup animations ───────────────────────────────────────── */ .todo-enter-active, .todo-leave-active, .todo-move { transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1); } .todo-enter-from, .todo-leave-to { opacity: 0; transform: translateX(-40px) scale(0.95); } .todo-leave-active { position: absolute; width: 100%; } </style> |
Key Lessons & Magic Details (Must Understand)
-
:key is mandatory Must be unique and stable (never use index as key in animated lists — causes bugs when order changes)
-
.move class Automatically added when items reorder → lets you animate position shifts smoothly
-
.leave-active + position: absolute Prevents layout jump when item is removed — item stays in place during exit animation
-
Staggering (optional – nice effect)
CSS012345678.todo-enter-active {transition-delay: calc(0.1s * var(--i));}Then in template:
vue012345678910<liv-for="(todo, index) in todos":key="todo.id":style="{ '--i': index }">
Quick Reference Table – <TransitionGroup> Essentials
| Class Name | When it appears | Typical CSS you write |
|---|---|---|
| *-enter-from | Before enter animation starts | opacity: 0; transform: translateX(-30px); |
| *-enter-active | During enter animation | transition: all 0.4s ease; |
| *-leave-to | End state of leave animation | opacity: 0; transform: translateX(30px); |
| *-leave-active | During leave animation | transition: all 0.3s ease; |
| *-move | When items reorder (most important) | transition: transform 0.4s ease; |
| *-leave-active + absolute | Prevents jump during removal | position: absolute; width: 100%; |
Pro Tips from Real Projects (Hyderabad 2026)
- Always use unique stable keys (database ID, UUID, not index)
- Keep transitions 0.3–0.5 seconds — longer feels sluggish
- Use cubic-bezier(0.4, 0, 0.2, 1) or ease-out — feels natural
- Combine with Prefers-reduced-motion media query (accessibility)
- For very long lists → consider virtual scrolling (vue-virtual-scroller) + TransitionGroup
- Test on mobile — animations feel different on 60Hz vs 120Hz
Your mini homework:
- Build the todo list above
- Add stagger delay (each new item appears 0.1s after previous)
- Add slide-from-right for new items, slide-to-left for removed
- Add “Clear completed” button that removes multiple items at once
Any part confusing? Want full examples for:
- Animated kanban board (drag & drop + TransitionGroup)?
- Chat message list with staggered enter?
- Animated router-view transitions between pages?
- TransitionGroup + <KeepAlive> for cached tabs?
Just tell me — we’ll animate the next beautiful list together 🚀
Happy animating from Hyderabad! 💙
