skip to content

In Vue 3, how do you stagger `<TransitionGroup>` item animations so each list item enters slightly after the previous one?

level: middleimportance: nice to knowfreq 22%

answer

  1. each item needs its position
  2. a data attribute carries it
  3. JS hooks read el.dataset
  4. call done when finished

basics

~10 s

Vue's TransitionGroup staggers by giving each item its index as a data attribute, then using JavaScript hooks with :css="false" that read el.dataset.index to delay each item's animation and call done when it finishes.

solid answer

~40 s

`<TransitionGroup>` animates each item independently, so staggering means giving each item its own delay. The documented pattern renders the index as a data attribute, `:data-index="index"`, and handles the animation in JavaScript hooks: `@before-enter` sets the start state, `@enter(el, done)` animates with a delay of `Number(el.dataset.index) * step` and calls `done` when finished, and `@leave(el, done)` does the reverse. Adding `:css="false"` tells Vue not to look for CSS classes or transition events, so for a hook that declares `done` it is the only completion signal and must be called (a hook without that parameter is treated as finished immediately). A CSS-only alternative is an inline `transition-delay` per item, which Vue takes into account when it measures the transition's total time. Keys stay mandatory, and a filtered list needs indexes that reflect the new order.

go deeper

for a junior

Know that staggering means a small per-item delay and that the item's index can be put on the element as a data attribute.

for a middle

Explain the data-attribute plus JavaScript-hook pattern, why :css="false" makes done the only completion signal, and the inline transition-delay alternative.

for a senior

Guard against leaks and sluggishness: always call done, cap delays on long lists, and keep keys stable so hooks attach to the right items.

for a principal

Decide where cascading motion earns its time in the product, and keep one shared stagger helper so timing stays consistent across lists.

## What staggering means here A **staggered** list animation starts each item's animation a little later than the previous one, so a newly loaded or filtered list cascades in rather than appearing all at once. `<TransitionGroup>` runs a separate enter, leave or move transition for **each item**, which is exactly what makes per-item delays possible; the group just needs to know where each item sits. ## The documented pattern: data attributes plus JavaScript hooks 1. Render the item's position as a **data attribute** so the DOM element carries it: `:data-index="index"`. 2. Listen to the JavaScript hooks that `<TransitionGroup>` shares with `<Transition>`: `@before-enter`, `@enter`, `@leave`. 3. Set `:css="false"` so Vue skips CSS class detection for this group. 4. In the hooks, read `el.dataset.index`, compute a delay and call `done` when the animation finishes. ```vue <script setup lang="ts"> import { computed, ref } from 'vue' const query = ref('') const all = ['Ada', 'Grace', 'Linus', 'Margaret', 'Barbara'] const names = computed(() => all.filter((n) => n.toLowerCase().includes(query.value.toLowerCase())), ) const STEP = 60 function onBeforeEnter(el: Element) { const h = el as HTMLElement h.style.opacity = '0' h.style.transform = 'translateY(8px)' } function onEnter(el: Element, done: () => void) { const h = el as HTMLElement const delay = Number(h.dataset.index) * STEP const anim = h.animate( [ { opacity: 0, transform: 'translateY(8px)' }, { opacity: 1, transform: 'none' }, ], { duration: 200, delay, fill: 'forwards' }, ) anim.onfinish = () => { h.style.opacity = '' h.style.transform = '' done() } } function onLeave(el: Element, done: () => void) { const h = el as HTMLElement const anim = h.animate([{ opacity: 1 }, { opacity: 0 }], { duration: 150 }) anim.onfinish = done } </script> <template> <input v-model="query" placeholder="Filter" /> <TransitionGroup tag="ul" :css="false" @before-enter="onBeforeEnter" @enter="onEnter" @leave="onLeave"> <li v-for="(name, index) in names" :key="name" :data-index="index">{{ name }}</li> </TransitionGroup> </template> ``` The example uses the browser's Web Animations API; any animation library works the same way as long as the hook calls `done` on completion. ## Why `:css="false"` and `done` matter | Setting | How Vue knows the item finished | |---|---| | Default (`css` true) | From CSS: it applies the transition classes and waits for the computed transition or animation duration | | `:css="false"` | Only from your hook calling `done` | With `:css="false"`, Vue does not add or wait on CSS classes. A hook written with only the `el` parameter is treated as synchronous: Vue calls `done` for it right after the hook returns. If `@enter` or `@leave` takes `done` as a parameter and never calls it, the transition never completes; for a leaving item that means the element is never removed. ## A CSS-only alternative For simple fades, per-item delays can stay in CSS. Bind an inline delay and keep the class-based transition: ```vue <li v-for="(name, i) in names" :key="name" :style="{ transitionDelay: `${i * 60}ms` }"> {{ name }} </li> ``` Vue computes how long a CSS transition lasts from the element's durations **and delays**, so each item's end is detected correctly. The trade-off is less control: easing curves per step, sequencing leaves differently from enters, or animating properties CSS transitions cannot tween are easier in JavaScript hooks. ## Pitfalls - **Indexes are positions at render time.** After filtering, the index attribute reflects the new order, so the cascade restarts from the top; that is usually what you want. - **Long lists make long cascades.** Cap the delay, for example `Math.min(index, 10) * STEP`, so the hundredth item does not wait six seconds. - **Keys are still required.** Staggering changes timing, not identity; unkeyed children get no hooks at all. - **Moves are separate.** Staggering enter and leave does not change how reordered items move; that is still the move class. ## Staggering the first render By default the hooks run only for items inserted **after** the group has mounted. To cascade a list in on first display, add the `appear` prop, which `<TransitionGroup>` shares with `<Transition>`: the enter hooks then also run for the items present at mount, and the data-attribute delays produce the same cascade on page load as after a filter change. ```vue <TransitionGroup tag="ul" appear :css="false" @before-enter="onBeforeEnter" @enter="onEnter" @leave="onLeave"> <li v-for="(name, index) in names" :key="name" :data-index="index">{{ name }}</li> </TransitionGroup> ``` ## Summary Stagger by giving each item its position through a data attribute and turning it into a delay, either in JavaScript hooks with `:css="false"` and a `done` call, or with an inline `transition-delay` for pure CSS transitions.

  • What happens if an `@leave(el, done)` hook with `:css="false"` never calls `done`?
    With `:css="false"` Vue has no CSS transition to wait for, so it relies entirely on `done`. If the hook never calls it, the leave never completes and the leaving element stays in the DOM. Always call `done` from the animation's completion callback.
  • How do you keep a stagger from becoming painfully slow on a long list?
    Cap the per-item delay, for example `Math.min(Number(el.dataset.index), 10) * step`, or scale the step down as the list grows. Beyond a handful of items, extra delay adds waiting rather than meaning, and the last items appear long after the user expected the list.

saying these in an interview costs you the question

  • TransitionGroup has a built-in stagger prop
  • With :css="false" Vue still waits for CSS transitionend before finishing
  • The item index is passed to the enter hook as an argument
  • A hook that declares a done parameter may skip calling it when :css="false" is set
  • Staggering enter animations also staggers move animations