Skip to content
Briebug

Developer Resource Center

Sharing our recent findings, expertise, and insights with the community.

Mastering Angular Animations with :increment and :decrement

Angular Animations Frontend by 14 min read

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 params for 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 */ }})
    ])
  ]
})
  • trigger wraps all animations for a UI region.

  • transition chooses when to run (e.g., :increment, :decrement, :enter, open => closed).

  • Inside a transition, you compose steps: style(...), animate(...), with optional orchestration via group(...) (parallel) and query(...) (target specific elements like :enter/:leave).

APIQuick 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 / :decrement match 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

SelectorFires when…Typical useNotes
:incrementbound number increasesCarousels (“Next”), steppers (“Step 1 → Step 2”), countersCleanly encodes direction.
:decrementbound number decreases“Back” buttons, undo, backward pagingPairs naturally with :increment.
:enterelement enters DOMFirst render of a panel, list items appearingAlias for void => *. Good to combine with :increment for initial state.
:leaveelement leaves DOMRemoving items, closing panelsAlias for * => void. Not always tied to :decrement.
from => toexact state pairSpecific state switchingMost explicit; more to maintain.
* (wildcard)any stateDefault/fallback transitionPut 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;
  }
}
  • :increment and :decrement decide which transition runs based on how index() 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 via AnimationOptions.params.

  • The @for block is used to render only the active slide, with track item.text to 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() accepts duration [delay] [easing] (e.g., "400ms ease-in-out"). Supported easings include ease, ease-in, ease-out, ease-in-out, or a cubic-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 to transition).

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

NeedBest fitWhy
Directional animation based on a numeric step:increment / :decrementEncodes direction with less code.
Animate initial mount / teardown:enter / :leaveAliases handle DOM attach/detach clearly.
Explicit A→B and B→Afrom => to or from <=> toMost readable when states are named.
“If nothing else matched” fallback=> *Ensure something animates in all other cases.
Orchestrate multiple elementsquery() + group()Target entering/leaving elements and run steps in parallel.

Real-world use cases where :increment/:decrement shine

ScenarioWhy it’s a fitExample
Carousels / SlidersDirection is inherent: “Next” vs “Back”. No extra flags required.Bind [@slide]="index". Animate left/right based on :increment/:decrement.
Steppers / WizardsStep numbers only go up/down; animate content accordingly.[@stepper]="currentStep". Use different animations per direction.
Pagination / List BrowsingPage index indicates direction; animate list container left/right or up/down.[@pageTransition]="pageNumber".
Search filter countsWhen a filter narrows (count decreases), gently collapse items; when it widens (count increases), stagger new items in.:increment vs :decrement with query().
Scoreboards / CountersNumbers 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 hints will-change: transform, opacity; isolation contain: paint; a11y fallback @media (prefers-reduced-motion: reduce) { … }.

  • Enable animations in a standalone app with provideAnimations() (or provideAnimationsAsync() if you can defer), in bootstrapApplication(...). Disable in tests with provideNoopAnimations().

  • 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 with group() 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 :increment and :decrement logic 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 => to or wildcard transitions are clearer.

  • You only have a binary toggle. A single open <=> closed transition 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/:decrement depend 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 :enter transition always runs when it’s attached. If you’re relying on :increment on first paint, combine with :enter (or use a sentinel like 1 or 0 in ordered transitions) to skip or specialize initial behavior. Also note that :leave may 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 track key/ID, which breaks animations. The guides stress using track with @for or trackBy with NgFor.

  • Not providing default params: If you reference {{duration}}/{{distance}} but forget defaults, you may get errors or unexpected behavior. Provide defaults in the transition(..., { 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.