Mastering Angular Animations with :increment and :decrement
Use Angular's :increment and :decrement transition selectors to run direction-aware animations for carousels, steppers, and counters as a bound number changes.
Angular’s :increment and :decrement selectors make it trivial to run different animations when a number bound to a trigger goes up or down (e.g., slide left on “Next”, slide right on “Back”). They’re designed for counters, steppers, carousels, and any UI where direction matters. You bind a number (or { value, params }) to the trigger, declare two transitions—one for :increment, one for :decrement—and Angular picks the right one automatically. You can also mix them with :enter, :leave, and wildcards to handle more cases.
What It Provides
-
Cleaner intent: You encode direction (“went up” vs “went down”) directly in the transition, not by juggling boolean flags or comparing states by hand.
-
Fewer branches: One numeric binding drives both the “forward” and “backward” animations—great for steppers, carousels, scoreboards, pagination, etc.
-
Composable: Combine with
:enter/:leave, wildcards, and query/group for coherent motion across complex UIs. -
Parametrizable: Pass
paramsfor duration, distance, and easing—no hard-coding.
The essential controls
Before digging in, let’s briefly review how Angular Animations are defined. This article assumes you have an understanding of how Angular Animations work.
@Component({
animations: [
trigger('name', [ // 1) TRIGGER (root)
transition(':increment', [ // 2) TRANSITION (which cases)
// 3) STEPS inside a transition run either sequentially (array order) or in parallel (group)
group([ // 3a) GROUP (parallel)
query(':enter', [ // 3b) QUERY (target subset)
style({...}), // 3c) STYLE (set styles now)
animate('350ms ease-out', style({...})) // 3d) ANIMATE (tween)
], { optional: true }),
query(':leave', [
style({...}),
animate('350ms ease-out', style({...}))
], { optional: true })
])
], { params: { /* defaults here */ }})
])
]
})
-
triggerwraps all animations for a UI region. -
transitionchooses when to run (e.g.,:increment,:decrement,:enter,open => closed). -
Inside a transition, you compose steps:
style(...),animate(...), with optional orchestration viagroup(...)(parallel) andquery(...)(target specific elements like:enter/:leave).
| API | Quick tip |
|---|---|
trigger(name, steps[]) | Each host element gets its own trigger instance. Keep names scoped (e.g., "carouselSlide"). |
transition(selector, steps[], options?) | Order matters—Angular runs the first matching transition. Put specific rules first. |
group(steps[]) | Great for animating :enter and :leave simultaneously (polished feel). |
query(selector, steps[], options?) | Select descendants (':enter', ':leave', CSS selectors) and animate them. Add { optional: true } when the selector might not match (more below). |
style(cssObj) | Think “keyframe”. Sets current styles immediately at this point in the timeline. The next animate will tween from these values (if they differ). |
animate(timing, [finalStyle?]) | Tween. Interpolates from current styles to finalStyle (or back to computed state) over timing ("350ms ease-in-out"). Accepts params like {{duration}}. |
What :increment and :decrement actually do
When the numerical expression bound to an animation trigger increases, Angular matches the :increment transition; when it decreases, it matches :decrement. This is part of the state-change expression syntax you pass to transition(...). You can mix these with other selectors (like :enter, specific from => to pairs, or wildcards), and Angular will fire the first matching transition (order matters).
trigger('slide', [
transition(':increment', [ /* forward animation */ ]),
transition(':decrement', [ /* backward animation */ ]),
// more specific pairs or fallbacks can follow
]);
Key points:
-
:increment/:decrementmatch based on numeric comparison of the trigger’s bound value. -
You can comma-separate multiple selectors in a single
transition. -
Transitions are matched in declaration order (more specific first; fallbacks later).
Quick comparison
| Selector | Fires when… | Typical use | Notes |
|---|---|---|---|
:increment | bound number increases | Carousels (“Next”), steppers (“Step 1 → Step 2”), counters | Cleanly encodes direction. |
:decrement | bound number decreases | “Back” buttons, undo, backward paging | Pairs naturally with :increment. |
:enter | element enters DOM | First render of a panel, list items appearing | Alias for void => *. Good to combine with :increment for initial state. |
:leave | element leaves DOM | Removing items, closing panels | Alias for * => void. Not always tied to :decrement. |
from => to | exact state pair | Specific state switching | Most explicit; more to maintain. |
* (wildcard) | any state | Default/fallback transition | Put after more specific transitions. |
Binding: number only vs { value, params }
You can bind a plain number:
<div [@slide]="index"></div>
Or pass an object with both value and params to drive dynamic timings and distances:
<div [@slide]="{ value: index, params: { duration: '350ms', distance: '100%', easing: 'ease-in-out' } }"></div>
Angular looks at value for the state and uses params as animation parameters. Default parameters are provided through AnimationOptions.params on functions like transition, group, query, animation, etc.
Heads-up: Trigger bindings convert values to strings for state matching, and booleans can be expressed as true / false or 1 / 0. For :increment / :decrement, bind and update numbers to ensure directional comparisons behave as intended.
Minimal example: slide a panel left on next, right on back
Below is an example that uses :increment and :decrement to move a “stage” horizontally.
import { Component, signal } from '@angular/core';
import { trigger, transition, style, animate, group, query } from '@angular/animations';
@Component({
selector: 'demo-carousel',
standalone: true,
template: `
<div class="controls">
<button (click)="prevH()" [disabled]="hIndex === 0">Prev</button>
<div class="dots">
@for (item of items; track item.text) {
<button [class.active]="$index === hIndex" (click)="gotoH($index)">
{{ $index + 1 }}
</button>
}
</div>
<button (click)="nextH()" [disabled]="hIndex === items.length - 1">Next</button>
</div>
<div
class="stage"
[@horizontalFadeInOut]="{
value: hIndex,
params: { distanceX: distanceXParam, duration: durationParam },
}"
>
@for (item of items; track item.text) {
@if ($index === hIndex) {
<div class="slide">
<h3>{{ item.title }}</h3>
<p>{{ item.text }}</p>
</div>
}
}
</div>
`,
styles: [`
.stage {
position: relative;
overflow: hidden;
min-height: 120px;
border: 1px solid #e5e5e5;
border-radius: 12px;
padding: 16px 20px;
background: #fafafa;
margin-bottom: 32px;
}
.slide {
position: absolute;
left: 0;
right: 0;
top: 0;
}
// ... remaining styles omitted for brevity
`],
animations: [
trigger('horizontalFadeInOut', [
transition(
':increment',
[
group([
query(
'.slide:enter',
[
style({ transform: 'translateX({{distanceX}})', opacity: 0 }),
animate(
'{{duration}}',
style({ transform: 'translateX(0)', opacity: 1 })
),
],
{ optional: true }
),
query(
'.slide:leave',
[
style({ transform: 'translateX(0)', opacity: 1 }),
animate(
'{{duration}}',
style({ transform: 'translateX(-{{distanceX}})', opacity: 0 })
),
],
{ optional: true }
),
]),
],
{ params: { distanceX: '24px', duration: '300ms' } }
),
transition(
':decrement',
[
group([
query(
'.slide:enter',
[
style({ transform: 'translateX(-{{distanceX}})', opacity: 0 }),
animate(
'{{duration}}',
style({ transform: 'translateX(0)', opacity: 1 })
),
],
{ optional: true }
),
query(
'.slide:leave',
[
style({ transform: 'translateX(0)', opacity: 1 }),
animate(
'{{duration}}',
style({ transform: 'translateX({{distanceX}})', opacity: 0 })
),
],
{ optional: true }
),
]),
],
{ params: { distanceX: '24px', duration: '300ms' } }
),
]),
]
})
export class DemoCarouselComponent {
// carousel indices
hIndex = 0;
// demo content
items = [
{ title: 'One', text: 'First slide' },
{ title: 'Two', text: 'Second slide' },
{ title: 'Three', text: 'Third slide' },
];
// controls - could remove properties and inline
distanceXAmount = 100;
distanceXUnit: '%' | 'px' = '%';
durationMs = 350;
easing = 'ease-in-out';
// derived params (read-only API to templates)
get distanceXParam() {
return `${this.distanceXAmount}${this.distanceXUnit}`;
}
get durationParam() {
return `${this.durationMs}ms ${this.easing}`;
}
// nav helpers
nextH() {
if (this.hIndex < this.items.length - 1) this.hIndex++;
}
prevH() {
if (this.hIndex > 0) this.hIndex--;
}
gotoH(i: number) {
this.hIndex = i;
}
}
-
:incrementand:decrementdecide which transition runs based on howindex()changes. -
query(':enter')/query(':leave')target the incoming and outgoing slide;group()runs both in parallel. -
Parameters (
{{duration}},{{distance}},{{easing}}) are supplied via the trigger binding and have defaults viaAnimationOptions.params. -
The
@forblock is used to render only the active slide, withtrack item.textto keep DOM identity stable for animations.
💻 Try it yourself: Explore this example live on StackBlitz. You can tweak the distance, duration, and easing controls to see how :increment and :decrement change the motion in real time.
Mixing with :enter, :leave, wildcards, and ordered fallbacks
You can combine selectors:
transition(':enter, * => 0, * => -1', []), // skip certain first renders
transition(':increment', [...]),
transition(':decrement', [...]),
transition('* => *', [ animate('200ms ease-out') ]) // last-resort fallback
Angular matches in the order you declare them. Put your more specific transitions first, and leave a catch-all at the end if you want a default behavior.
Aliases :enter/:leave are just void => * and * => void, which is handy when combining with structural changes.
Parameterizing timing and distance
-
animate()acceptsduration [delay] [easing](e.g.,"400ms ease-in-out"). Supported easings includeease,ease-in,ease-out,ease-in-out, or acubic-bezier(...). -
Binding shape:
[@trigger]="{ value: index, params: { duration, distance, easing } }".
This lets you expose UI controls (sliders/inputs) to tweak distance and duration at runtime—great for demos and for tuning motion during design reviews or as part of the UI/UX if needed. Add params to transitions and styles, then supply them via the trigger binding. Defaults go in AnimationOptions.params.
transition(':increment', [
style({ transform: 'translateX({{distance}})', opacity: 0 }),
animate('{{duration}} {{easing}}', style({ transform: 'translateX(0)', opacity: 1 }))
], {
params: { distance: '100%', duration: '350ms', easing: 'ease-in-out' } // defaults
})
Here:
-
{{distance}},{{duration}}, and{{easing}}are placeholders. -
Angular looks for those values in the trigger binding you pass in your template.
-
If you don’t supply a value, Angular falls back to the defaults you put in
params(third argument totransition).
Template binding with params:
<div [@slide]="{
value: index,
params: { distance: '200%', duration: '500ms', easing: 'linear' }
}">
...
</div>
Now the same animation logic will run, but with your runtime-supplied parameters.
Feature matrix: choosing the right transition selector
| Need | Best fit | Why |
|---|---|---|
| Directional animation based on a numeric step | :increment / :decrement | Encodes direction with less code. |
| Animate initial mount / teardown | :enter / :leave | Aliases handle DOM attach/detach clearly. |
| Explicit A→B and B→A | from => to or from <=> to | Most readable when states are named. |
| “If nothing else matched” fallback | => * | Ensure something animates in all other cases. |
| Orchestrate multiple elements | query() + group() | Target entering/leaving elements and run steps in parallel. |
Real-world use cases where :increment/:decrement shine
| Scenario | Why it’s a fit | Example |
|---|---|---|
| Carousels / Sliders | Direction is inherent: “Next” vs “Back”. No extra flags required. | Bind [@slide]="index". Animate left/right based on :increment/:decrement. |
| Steppers / Wizards | Step numbers only go up/down; animate content accordingly. | [@stepper]="currentStep". Use different animations per direction. |
| Pagination / List Browsing | Page index indicates direction; animate list container left/right or up/down. | [@pageTransition]="pageNumber". |
| Search filter counts | When a filter narrows (count decreases), gently collapse items; when it widens (count increases), stagger new items in. | :increment vs :decrement with query(). |
| Scoreboards / Counters | Numbers going up vs down can flip different directions to emphasize trend. | [@ticker]="score". Use vertical flip up on :increment, down on :decrement. |
The Angular Complex Sequences guide demonstrates a filters example using :increment to animate entering items and :decrement to animate leaving items—including a workaround to skip first-load animations with :enter, * => 0, * => -1. That pattern is a great reference when your numeric state has “special” values.
Implementation checklist
- Set the stage (CSS):
.stage { position: relative; overflow: hidden; min-height: … }— Contains stacked slides and hides off-screen motion.
.slide { position: absolute; } — Layers each slide on top so :enter/:leave can animate simultaneously.
-
Optional: CSS vars
-distance,-duration,-easing; perf hintswill-change: transform, opacity; isolationcontain: paint; a11y fallback@media (prefers-reduced-motion: reduce) { … }. -
Enable animations in a standalone app with
provideAnimations()(orprovideAnimationsAsync()if you can defer), inbootstrapApplication(...). Disable in tests withprovideNoopAnimations(). -
Bind a number to your trigger:
[@slide]="index"or[@slide]="{ value: index, params: { ... } }". -
Declare two transitions: one for
:increment, one for:decrement. Put them before any catch-all transitions. -
Use
query(':enter')/query(':leave')to target the incoming/outgoing elements inside the stage; wrap withgroup()to run in parallel. -
Set defaults via
AnimationOptions.params, and optionally expose distance/duration/easing from component state for easy tuning. -
For lists or sets, ensure stable identity with
@for (...; track item.id)so Angular animates the correct DOM node.
FAQ
Q: Can I combine :increment with :enter?
Yes—either by separate transitions (:enter first, then :increment) or by mixing selectors in one transition (comma-separated). Transition order determines which one runs first.
Q: Does :decrement trigger when the value stays the same?
No. It only matches when the numeric value decreases between two change detections. If you need a “same value” animation, add an explicit * => * fallback (or a custom predicate transition).
Q: How do I pass different distances per breakpoint?
Use params but compute them in your component (e.g., '100vw' on mobile, '600px' on desktop) and feed via the trigger binding’s params.
Q: What about accessibility / reduced motion?
If you migrate to CSS, prefer the prefers-reduced-motion media query to dial down or disable animations for users who opt out.
A note on migration and performance
Angular’s docs explicitly encourage moving away from the @angular/animations package where feasible: native CSS animations are smaller and can benefit from hardware acceleration. If you’re building something new and don’t need programmatic orchestration like query() or animateChild(), consider CSS first. If you’re maintaining a mature app or need numeric direction logic, :increment/:decrement remain a powerful, explicit tool.
When not to use :increment / :decrement
-
You’re starting new work and don’t need animation logic in TypeScript. Angular recommends migrating to native CSS animations for new code. CSS animations are smaller and can leverage hardware acceleration; the animations package can add ~60 kB to your JS bundle. If you can express the motion in CSS and trigger via classes, that’s often simpler and leaner. However, one might argue complex
:incrementand:decrementlogic is easier to write and maintain with this package. -
Your state isn’t inherently numeric. If you’re animating named states (“open”, “closed”, “inProgress”), explicit
from => toor wildcard transitions are clearer. -
You only have a binary toggle. A single
open <=> closedtransition may be enough without introducing numeric indices. -
You need fine-grained CSS control or Web Animations API features. Many effects are easier and more portable with pure CSS and media queries (e.g.,
prefers-reduced-motion).
Common pitfalls & how to avoid them
-
Binding non-numeric values:
:increment/:decrementdepend on numeric comparisons. Ensure you’re updating a number (e.g.,index: number). Remember that trigger bindings convert values to strings internally for matching; don’t pass"2"as a string and expect numeric behavior. -
First render quirks: An element’s
:entertransition always runs when it’s attached. If you’re relying on:incrementon first paint, combine with:enter(or use a sentinel like1or0in ordered transitions) to skip or specialize initial behavior. Also note that:leavemay not run if a parent removes the element “without warning.” -
Transition order: Angular uses the first match. Put specific transitions (like
:enter,:increment,open => closed) before broader patterns (=> *). Angular -
Animating lists without stable identity: If items reorder, Angular can’t keep track without a proper
trackkey/ID, which breaks animations. The guides stress usingtrackwith@forortrackBywithNgFor. -
Not providing default params: If you reference
{{duration}}/{{distance}}but forget defaults, you may get errors or unexpected behavior. Provide defaults in thetransition(..., { params: {...} })options.
Wrap-up
:increment and :decrement give you a clean way to express direction in your animations—perfect for carousels, steppers, and counters. Pair them with :enter/:leave and helpers like query() or group() to create transitions that feel polished instead of patched together. Keep your bindings simple: drive them off a number, pass params for distance or easing, and order transitions carefully for predictable results.
The bigger takeaway: don’t reach for @angular/animations everywhere. Use it where directional or state-driven motion adds clarity, and fall back to plain CSS when that’s enough. This keeps your animation layer both powerful and maintainable.
Armed with these patterns, you can implement smooth, directional animations with minimal code—and decide, case by case, when the animations package is the right tool for the job.