Building Count-Up Animation with Angular and RxJS

Marko Stanimirović - Dec 21 '21 - - Dev Community

Cover photo by Andy Holmes on Unsplash.

This article explains how to build a count-up animation in Angular in a reactive way. We are going to build a count-up directive from scratch without third-party libraries. The final result will look like this:

Count-Up GIF

Let's get started!

Generating Directive with Angular CLI

To create a directive in Angular, run the following command:

ng generate directive count-up
Enter fullscreen mode Exit fullscreen mode

The Angular CLI will generate a count-up.directive.ts file that contains an empty directive:

@Directive({
  selector: '[countUp]'
})
export class CountUpDirective {
  constructor() {}
}
Enter fullscreen mode Exit fullscreen mode

Defining Inputs

The CountUpDirective has two inputs: count and animation duration, where the name of the count input is the same as the name of the directive selector. Using the CountUpDirective in the template will look like this:

<p [countUp]="200" [duration]="5000"></p>
Enter fullscreen mode Exit fullscreen mode

These inputs are defined in the CountUpDirective as follows:

@Directive({
  selector: '[countUp]'
})
export class CountUpDirective {
  @Input('countUp') // input name is the same as selector name
  set count(count: number) {}

  @Input()
  set duration(duration: number) {}
}
Enter fullscreen mode Exit fullscreen mode

As you can see, inputs are defined as setters. Input values will be emitted to RxJS subjects, which will allow us to reactively listen to their changes, without using the OnChanges lifecycle hook.

Defining Local State

The CountUpDirective has two local state slices that will be stored in behavior subjects:

@Directive({
  selector: '[countUp]'
})
export class CountUpDirective {
  // default count value is 0
  private readonly count$ = new BehaviorSubject(0);
  // default duration value is 2000 ms
  private readonly duration$ = new BehaviorSubject(2000);
}
Enter fullscreen mode Exit fullscreen mode

New input values will then be emitted to these subjects as inputs change:

@Directive({
  selector: '[countUp]'
})
export class CountUpDirective {
  private readonly count$ = new BehaviorSubject(0);
  private readonly duration$ = new BehaviorSubject(2000);

  @Input('countUp')
  set count(count: number) {
    // emit a new value to the `count$` subject
    this.count$.next(count);
  }

  @Input()
  set duration(duration: number) {
    // emit a new value to the `duration$` subject
    this.duration$.next(duration);
  }
}
Enter fullscreen mode Exit fullscreen mode

The next step is to build the currentCount$ observable that will be used to display the current count in the template.

Calculating Current Count

To calculate the current count we need values of the count$ and duration$ subjects. We will use the combineLatest operator to reset the calculation of the current count each time the count$ or duration$ changes. The next step is to switch the outer observable with an interval that starts with 0, increases current count over time, then slows down, and ends with the count value when the animation duration expires:

private readonly currentCount$ = combineLatest([
  this.count$,
  this.duration$,
]).pipe(
  switchMap(([count, duration]) => {
    // get the time when animation is triggered
    const startTime = animationFrameScheduler.now();

    // use `animationFrameScheduler` for better rendering performance
    return interval(0, animationFrameScheduler).pipe(
      // calculate elapsed time
      map(() => animationFrameScheduler.now() - startTime),
      // calculate progress
      map((elapsedTime) => elapsedTime / duration),
      // complete when progress is greater than 1
      takeWhile((progress) => progress <= 1),
      // apply quadratic ease-out function
      // for faster start and slower end of counting
      map((progress) => progress * (2 - progress)),
      // calculate current count
      map((progress) => Math.round(progress * count)),
      // make sure that last emitted value is count
      endWith(count),
      distinctUntilChanged()
    );
  }),
);
Enter fullscreen mode Exit fullscreen mode

We use animationFrameScheduler instead of the default asyncScheduler for better rendering performance. When the animationFrameScheduler is used with interval, the first argument must be 0. Otherwise, it falls back to the asyncScheduler. In other words, the following implementation of currentCount$ uses asyncScheduler under the hood, although the animationFrameScheduler is passed as a second argument to the interval function:

private readonly currentCount$ = combineLatest([
  this.count$,
  this.duration$,
]).pipe(
  switchMap(([count, animationDuration]) => {
    const frameDuration = 1000 / 60; // 60 frames per second
    const totalFrames = Math.round(animationDuration / frameDuration);

    // interval falls back to `asyncScheduler`
    // because the `frameDuration` is different from 0
    return interval(frameDuration, animationFrameScheduler).pipe(
      // calculate progress
      map((currentFrame) => currentFrame / totalFrames), 
      // complete when progress is greater than 1
      takeWhile((progress) => progress <= 1),
      // apply quadratic ease-out function
      map((progress) => progress * (2 - progress)),
      // calculate current count
      map((progress) => Math.round(progress * count)),
      // make sure that last emitted value is count
      endWith(count),
      distinctUntilChanged()
    );
  })
);
Enter fullscreen mode Exit fullscreen mode

💡 If you're not familiar with the animationFrameScheduler and its advantages for updating the DOM over the asyncScheduler, take a look at the resources section.

Displaying Current Count

To render the current count within the directive's host element, we need an instance of Renderer2 and a reference to the host element. Both can be injected through the constructor. We will also inject the Destroy provider that will help us to unsubscribe from the currentCount$ observable when the CountUpDirective is destroyed:

@Directive({
  selector: '[countUp]',
  // `Destroy` is provided at the directive level
  providers: [Destroy],
})
export class CountUpDirective {
  constructor(
    private readonly elementRef: ElementRef,
    private readonly renderer: Renderer2,
    private readonly destroy$: Destroy
  ) {}
}
Enter fullscreen mode Exit fullscreen mode

💡 Take a look at this article to learn more about Destroy provider.

Then we need to create a method that listens to the currentCount$ changes and displays emitted values within the host element:

private displayCurrentCount(): void {
  this.currentCount$
    .pipe(takeUntil(this.destroy$))
    .subscribe((currentCount) => {
      this.renderer.setProperty(
        this.elementRef.nativeElement,
        'innerHTML',
        currentCount
      );
    });
}
Enter fullscreen mode Exit fullscreen mode

The displayCurrentCount method will be called in the ngOnInit method.

Wrap-Up

The final version of the CountUpDirective looks like this:

/**
 * Quadratic Ease-Out Function: f(x) = x * (2 - x)
 */
const easeOutQuad = (x: number): number => x * (2 - x);

@Directive({
  selector: '[countUp]',
  providers: [Destroy],
})
export class CountUpDirective implements OnInit {
  private readonly count$ = new BehaviorSubject(0);
  private readonly duration$ = new BehaviorSubject(2000);

  private readonly currentCount$ = combineLatest([
    this.count$,
    this.duration$,
  ]).pipe(
    switchMap(([count, duration]) => {
      // get the time when animation is triggered
      const startTime = animationFrameScheduler.now();

      return interval(0, animationFrameScheduler).pipe(
        // calculate elapsed time
        map(() => animationFrameScheduler.now() - startTime),
        // calculate progress
        map((elapsedTime) => elapsedTime / duration),
        // complete when progress is greater than 1
        takeWhile((progress) => progress <= 1),
        // apply quadratic ease-out function
        // for faster start and slower end of counting
        map(easeOutQuad),
        // calculate current count
        map((progress) => Math.round(progress * count)),
        // make sure that last emitted value is count
        endWith(count),
        distinctUntilChanged()
      );
    }),
  );

  @Input('countUp')
  set count(count: number) {
    this.count$.next(count);
  }

  @Input()
  set duration(duration: number) {
    this.duration$.next(duration);
  }

  constructor(
    private readonly elementRef: ElementRef,
    private readonly renderer: Renderer2,
    private readonly destroy$: Destroy
  ) {}

  ngOnInit(): void {
    this.displayCurrentCount();
  }

  private displayCurrentCount(): void {
    this.currentCount$
      .pipe(takeUntil(this.destroy$))
      .subscribe((currentCount) => {
        this.renderer.setProperty(
          this.elementRef.nativeElement,
          'innerHTML',
          currentCount
        );
      });
  }
}
Enter fullscreen mode Exit fullscreen mode

Demo

Resources

Peer Reviewers

Thank you Tim for giving me helpful suggestions on this article!

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .