In Angular's legacy animations DSL, how do query(), stagger(), group() and sequence() combine to build a staggered list reveal?
answer
- trigger on the container
- find the entering rows
- offset each start time
- parallel versus one-by-one
- empty queries throw
basics
~20 sPut a trigger on the list container; its transition uses query(':enter', ...) to find the new rows and stagger(60, [...]) to delay each row's animation. group() runs steps in parallel, sequence() one after another, and a step array is a sequence by default.
solid answer
~40 sThe trigger sits on the container, for example `[@listReveal]="rows().length"`, so any change in the row count runs `transition('* => *', [...])`. Inside, `query(':enter', [style({opacity: 0}), stagger(60, [animate('300ms ease-out', style({opacity: 1}))])], {optional: true})` finds the rows being inserted, sets their start style, and starts each one 60 ms after the previous. `stagger()` is only valid inside a `query()`, and a negative value reverses the order. `query()` throws if it matches nothing, which is why `{optional: true}` matters when a change adds no rows; `limit` caps how many elements it takes. `group([...])` runs its steps in parallel and waits for all of them before the next step, while `sequence([...])` runs them one by one, which is also what a plain array does.
code
ts · 36 linesimport {Component, input} from '@angular/core';
import {animate, query, stagger, style, transition, trigger} from '@angular/animations';
interface OrderRow {
id: string;
label: string;
}
@Component({
selector: 'app-order-list',
template: `
<ul [@listReveal]="rows().length">
@for (row of rows(); track row.id) {
<li class="row">{{ row.label }}</li>
}
</ul>
`,
animations: [
trigger('listReveal', [
transition('* => *', [
query(':leave', [stagger(40, [animate('150ms', style({opacity: 0}))])], {optional: true}),
query(
':enter',
[
style({opacity: 0, transform: 'translateY(-8px)'}),
stagger(60, [animate('300ms ease-out', style({opacity: 1, transform: 'none'}))]),
],
{optional: true},
),
]),
]),
],
})
export class OrderList {
readonly rows = input.required<OrderRow[]>();
}go deeper
Recognise that query() finds inner elements and stagger() spaces their start times, with the trigger on the container.
Explain the full recipe: container trigger, :enter and :leave queries, optional: true, stagger inside query, and group versus sequence.
Diagnose zero-match errors, reordering glitches fixed by tracking, and main-thread cost on long lists; know that query() ignores emulated encapsulation.
Decide whether list choreography justifies keeping a deprecated engine, or whether CSS delays with an index variable meet the design with less runtime.
## The scenario A legacy dashboard loads a list of orders and reveals the rows one after another, each fading and sliding in a little after the one before. In the `@angular/animations` DSL this is built from four functions that work on **groups of elements and steps** rather than on a single element. | Function | What it does | |---|---| | `query(selector, steps, options?)` | Finds elements **inside** the animated element and runs steps on them. | | `stagger(timing, steps)` | Inside a query, delays each matched element's steps by an increasing offset. | | `group([...])` | Runs steps **in parallel**; the next step waits until all have finished. | | `sequence([...])` | Runs steps **one after another**. An array of steps is already a sequence by default. | ## Where the trigger goes A stagger needs one animation that can see all rows, so the trigger is placed on the **container**, not on each row. Binding it to something that changes when rows change, such as `rows().length`, means a `'* => *'` transition runs every time the list grows or shrinks. The rows themselves are plain elements rendered by `@for`. ## Building the reveal step by step 1. `query(':enter', [...], {optional: true})` selects the rows being **inserted** in this change. Besides normal CSS selectors, `query()` understands tokens: `:enter`, `:leave`, `:animating`, `:self`, `@triggerName` and `@*`. 2. A leading `style({opacity: 0, transform: 'translateY(-8px)'})` puts every matched row in its starting position immediately. 3. `stagger(60, [animate('300ms ease-out', style({opacity: 1, transform: 'none'}))])` starts the first row at once, the second 60 ms later, the third 120 ms later, and so on. 4. A second `query(':leave', stagger(40, [animate('150ms', style({opacity: 0}))]), {optional: true})` can fade removed rows out the same way. ## The rules that bite - **Empty queries throw.** By default `query()` raises an error when it matches zero elements (*query(":enter") returned zero elements*). A change that only removes rows has no entering rows, so the `:enter` query needs `{optional: true}`. - **`stagger()` needs a `query()`.** Used anywhere else it throws *stagger() can only be used inside of query()*. - **Negative stagger reverses.** `stagger(-60, ...)` starts with the last matched element, handy for collapsing a list bottom-up. - **`limit`.** `query('.row', ..., {limit: 10})` takes only the first ten matches; a negative limit takes them from the end. - **Only real entries count.** `:enter` and `:leave` queries find elements inserted or removed by control flow or a `ViewContainerRef`, not elements that merely arrive inside a parent that entered. - **Track by identity.** With `@for`, `track row.id` lets the engine tell a moved row from a removed one and a new one; with `*ngFor`, a `TrackByFunction` does the same. ## Parallel and sequential steps `group()` is for steps that should overlap, for example fading opacity over 200 ms while moving `transform` over 400 ms with a different easing. The step after a `group()` waits until its **longest** member finishes. `sequence()` is the explicit form of the default: each step starts when the previous one ends. Nesting them lets you say "fade the header and slide the toolbar together, then stagger the rows". ## Performance and maintenance notes - The engine computes every row's keyframes in JavaScript at the start of the transition, so a stagger over hundreds of rows is work on the main thread; `limit` is a cheap safeguard. - `ViewEncapsulation.Emulated` does not scope `query()`: a query at the top of a component tree can reach elements deep inside child components. Under `ViewEncapsulation.ShadowDom` the engine cannot see into shadow roots, so avoid animating across them. ## Reading an existing reveal in code review - Check that the trigger's bound value **changes** whenever rows change; binding a constant means the transition never runs again after the first render. - Check that **both** queries are optional unless the transition can only ever add, or only ever remove, rows. - Check the total duration: 40 rows at a 60 ms stagger means the last row starts 2.34 seconds after the first, which is usually too slow for a data table. - Check how rows are tracked; if the list is rebuilt from fresh objects and tracked by object identity, every refresh destroys and re-creates the rows, so they all leave and enter again. ## The native equivalent The whole package is deprecated since v20.2. The migration guide replaces this recipe with CSS: give each row an index custom property, compute `transition-delay` or `animation-delay` from it, and use `@starting-style` or `animate.enter` for the entrance. Knowing the legacy recipe is what lets you translate it faithfully.
- The reveal works on first load but throws when the user deletes a row. What is the likely cause?Deleting a row runs the `'* => *'` transition with no entering elements, so `query(':enter', ...)` matches nothing and throws by default. Adding `{optional: true}` to the query, and to the `:leave` query for the opposite case, lets a change with no matches pass silently.
- How would you make the rows collapse from the bottom up?Use a negative stagger timing, for example `stagger(-40, [...])` inside the `:leave` query. The engine then computes each element's delay from the end of the list, so the last row starts first and the first row finishes last.
Like a stadium wave: query() picks who takes part, stagger() tells each person to stand up a fixed beat after their neighbour, group() is several sections standing at once, and sequence() is one section finishing before the next begins.
saying these in an interview costs you the question
- stagger() can be used directly inside transition() without a query().
- query() quietly does nothing when it matches zero elements.
- group() runs its steps one after another.
- An array of steps in a transition runs in parallel by default.
- The stagger trigger belongs on each row rather than on the container.