Building Count-Up Animation with Angular and RxJS
Tags: angular, animations, rxjs
Published: 2021-12-21 11:20:12

*Cover photo by [Andy Holmes](https://unsplash.com/photos/rCbdp8VCYhQ) 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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/d3li2bbn80pb7nsw58s4.gif)

Let's get started!

## Generating Directive with Angular CLI

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

```sh
ng generate directive count-up
```

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

```ts
@Directive({
  selector: '[countUp]'
})
export class CountUpDirective {
  constructor() {}
}
```

## 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:

```html
<p [countUp]="200" [duration]="5000"></p>
```

These inputs are defined in the `CountUpDirective` as follows:

```ts
@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) {}
}
```

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:

```ts
@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);
}
```

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

```ts
@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);
  }
}
```

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:

```ts
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()
    );
  }),
);
```

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:

```ts
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()
    );
  })
);
```

> 💡 If you're not familiar with the `animationFrameScheduler` and its advantages for updating the DOM over the `asyncScheduler`, take a look at the [resources](#resources) section.

## Displaying Current Count

To render the current count within the directive's host element, we need an instance of [`Renderer2`](https://angular.io/api/core/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:

```ts
@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
  ) {}
}
```

> 💡 Take a look at [this article](https://dev.to/this-is-angular/dry-way-to-manage-subscriptions-in-angular-components-256j) 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:

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

The `displayCurrentCount` method will be called in the `ngOnInit` method.

## Wrap-Up

The final version of the `CountUpDirective` looks like this:

```ts
/**
 * 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
        );
      });
  }
}
```

## [Demo](https://stackblitz.com/edit/count-up-animation-with-angular-and-rxjs?file=src%2Fapp%2Fcount-up.directive.ts)

{% stackblitz count-up-animation-with-angular-and-rxjs view=preview %}

## Resources

- [Official docs of the `requestAnimationFrame` function](https://developer.mozilla.org/en-US/docs/Web/API/window/requestAnimationFrame)
- [Official docs of the `animationFrameScheduler`](https://rxjs.dev/api/index/const/animationFrameScheduler)
- [DRY Way to Manage Subscriptions in Angular Components](https://dev.to/this-is-angular/dry-way-to-manage-subscriptions-in-angular-components-256j)

## Peer Reviewers

- [Tim Deschryver](https://twitter.com/tim_deschryver)

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