Chapter 52: Vue TransitionGroup Component
Vue’s <TransitionGroup> component — one of the most satisfying and most frequently used animation tools in real Vue applications.
This is the specialized version of <Transition> that exists only for lists — that is, for content generated by v-for.
Without <TransitionGroup>, when items appear, disappear, or change order in a list, they just snap into or out of existence — no motion, no feeling of life.
With <TransitionGroup>, every single item can:
- smoothly fade/slide/scale in when added
- smoothly fade/slide/scale out when removed
- gently slide to its new position when the list is reordered, filtered, or sorted
That last part — smooth sliding when order changes — is the killer feature that makes todo lists, kanban boards, search results, chat messages, product grids, notification lists, etc. feel modern and premium.
Quick Comparison: <Transition> vs <TransitionGroup>
| Feature | <Transition> | <TransitionGroup> (for v-for) |
|---|---|---|
| Works with | single element (v-if / v-show / dynamic :is) | multiple elements generated by v-for |
| Animates position changes when order shifts? | No | Yes — items smoothly move to new positions |
| Requires :key on children? | Recommended | Mandatory — and must be unique & stable |
| Renders a wrapper element? | No (unless you set tag) | Yes — defaults to <span>, usually set tag=”ul” |
| Has special .move class? | No | Yes — this is what animates reordering |
| Most common real-world use-cases | modals, alerts, dropdowns, tab content | todo lists, cards, tables rows, messages, search results |
Real, Complete Example – Animated Todo List (production 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 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 |
<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> <!-- The star of the show --> <TransitionGroup name="list" tag="ul"> <li v-for="todo in filteredTodos" :key="todo.id" <!-- MUST be unique & stable --> 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> <div class="filters"> <button v-for="f in filters" :key="f.value" :class="{ active: filter === f.value }" @click="filter = f.value" > {{ f.label }} </button> </div> <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 properly', done: true }, { id: 2, text: 'Never forget :key again', done: false }, { id: 3, text: 'Make lists feel alive', done: false } ]) const filter = ref<'all' | 'active' | 'completed'>('all') const filteredTodos = computed(() => { if (filter.value === 'active') return todos.value.filter(t => !t.done) if (filter.value === 'completed') return todos.value.filter(t => t.done) return todos.value }) const remaining = computed(() => todos.value.filter(t => !t.done).length) const filters = [ { value: 'all', label: 'All' }, { value: 'active', label: 'Active' }, { value: 'completed',label: 'Completed' } ] function addTask() { if (!newTask.value.trim()) return todos.value.push({ id: Date.now(), // simple stable ID for demo 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; } .filters button { margin-left: 0.8rem; padding: 0.4rem 1rem; border-radius: 6px; background: #f3f4f6; border: none; cursor: pointer; } .filters .active { background: #3b82f6; color: white; } /* ──────────────────────────────────────── TransitionGroup classes – the real magic ───────────────────────────────────────── */ .list-enter-active, .list-leave-active, .list-move { transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1); } .list-enter-from, .list-leave-to { opacity: 0; transform: translateX(-40px) scale(0.95); } .list-leave-active { position: absolute; /* prevents jump during removal */ width: 100%; } </style> |
The 6 Classes <TransitionGroup> Uses (Must Memorize)
| Class suffix | When it appears | Typical CSS you write | Duration |
|---|---|---|---|
| -enter-from | Before item enters | opacity: 0; transform: translateX(-40px); | — |
| -enter-active | During enter animation | transition: all 0.4s ease; | active |
| -leave-to | End state of leave animation | opacity: 0; transform: translateX(40px); | — |
| -leave-active | During leave animation | transition: all 0.4s ease; position: absolute; | active |
| -move | When items reorder / shift position | transition: transform 0.4s ease; | active |
| -leave-active + absolute | Prevents layout jump during removal | position: absolute; width: 100%; | — |
Why :key is mandatory here (and why index is dangerous)
Without correct :key:
- Delete first item → Vue reuses DOM elements → animations look wrong
- Reorder list → no .move animation → items jump instantly
- Input focus lost when deleting above items
With correct :key=”todo.id”:
- Vue knows exactly which item is being removed/added/moved
- .move class triggers → items slide to new positions smoothly
- Child component state (checkbox, input value) stays with the correct item
Pro Tips from Real Projects (Hyderabad 2026)
-
Always use unique, stable :key (database ID, UUID, Date.now() for local items)
-
Never use :key=”index” in dynamic lists — only okay for static lists
-
Use .move class for reordering animations — it’s what makes sortable lists feel premium
-
Add stagger delay for nicer effect:
CSS012345678.list-enter-active {transition-delay: calc(0.08s * var(--i));}vue0123456<li :style="{ '--i': index }" ...> -
Combine with <KeepAlive> when list is inside tabs/router-view
-
Respect prefers-reduced-motion for accessibility
CSS012345678910@media (prefers-reduced-motion: reduce) {.list-enter-active, .list-leave-active, .list-move {transition: none !important;}}
Your mini homework:
- Build the todo list above
- Add stagger delay (each new item appears 80ms after previous)
- Add “Clear completed” button → watch multiple items leave smoothly
- Try removing :key → see how broken the animations & focus become
Any part confusing? Want full examples for:
- Drag-and-drop sortable list with TransitionGroup?
- Chat message list with staggered enter + avatars?
- Animated filter/search results?
- TransitionGroup inside <router-view>?
Just tell me — we’ll animate the next beautiful list together 🚀
