Angular 4 - Scroll Animation

I spent days trying to figure this out. Being a newbie I tried many things and none of them work. Finally, I have a solution so I will post it here.

There are 2 steps:

  1. Animate when things appear.
  2. Make things appear when scrolling.

Part 1: I found out these two great tutorials for newbies:

  1. The most basic one
  2. The one that actually animates when stuff appears

Part 2: I simply find the solution in this answer


Part 1 Step by Step:

  1. Add the line import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; to /src/app/app.module.ts and then also:
@NgModule({
  // Other arrays removed
  imports: [
    // Other imports
    BrowserAnimationsModule
  ],
})
  1. In the component.ts you want to animate, add: import { trigger,state,style,transition,animate } from '@angular/animations'; And then:
@Component({
  // Here goes the selector and templates and etc.
  animations: [
    trigger('fadeInOut', [
      state('void', style({
        opacity: 0
      })),
      transition('void <=> *', animate(1000)),
    ]),
  ]
})
  1. Finally, in the HTML item you want to animate, add [@fadeInOut].

If everything was done correctly, you should now have an animation (but it happens as soon as the webpage loads and not when you scroll.

Part 2 Step by Step:

  1. Create a file .ts like for example appear.ts and copy-paste this code:
import {
    ElementRef, Output, Directive, AfterViewInit, OnDestroy, EventEmitter
  } from '@angular/core';
  import { Observable, Subscription, fromEvent } from 'rxjs';
  import { startWith } from 'rxjs/operators';
  //import 'rxjs/add/observable/fromEvent';
  //import 'rxjs/add/operator/startWith';



  @Directive({
    selector: '[appear]'
  })
  export class AppearDirective implements AfterViewInit, OnDestroy {
    @Output()
    appear: EventEmitter<void>;

    elementPos: number;
    elementHeight: number;

    scrollPos: number;
    windowHeight: number;

    subscriptionScroll: Subscription;
    subscriptionResize: Subscription;

    constructor(private element: ElementRef){
      this.appear = new EventEmitter<void>();
    }

    saveDimensions() {
      this.elementPos = this.getOffsetTop(this.element.nativeElement);
      this.elementHeight = this.element.nativeElement.offsetHeight;
      this.windowHeight = window.innerHeight;
    }
    saveScrollPos() {
      this.scrollPos = window.scrollY;
    }
    getOffsetTop(element: any){
      let offsetTop = element.offsetTop || 0;
      if(element.offsetParent){
        offsetTop += this.getOffsetTop(element.offsetParent);
      }
      return offsetTop;
    }
    checkVisibility(){
      if(this.isVisible()){
        // double check dimensions (due to async loaded contents, e.g. images)
        this.saveDimensions();
        if(this.isVisible()){
          this.unsubscribe();
          this.appear.emit();
        }
      }
    }
    isVisible(){
      return this.scrollPos >= this.elementPos || (this.scrollPos + this.windowHeight) >= (this.elementPos + this.elementHeight);
    }

    subscribe(){
      this.subscriptionScroll = fromEvent(window, 'scroll').pipe(startWith(null))
        .subscribe(() => {
          this.saveScrollPos();
          this.checkVisibility();
        });
      this.subscriptionResize = fromEvent(window, 'resize').pipe(startWith(null))
        .subscribe(() => {
          this.saveDimensions();
          this.checkVisibility();
        });
    }
    unsubscribe(){
      if(this.subscriptionScroll){
        this.subscriptionScroll.unsubscribe();
      }
      if(this.subscriptionResize){
        this.subscriptionResize.unsubscribe();
      }
    }

    ngAfterViewInit(){
      this.subscribe();
    }
    ngOnDestroy(){
      this.unsubscribe();
    }
  }
  1. Import it using import {AppearDirective} from './timeline/appear';and add it to the imports as:
@NgModule({
  declarations: [
    // Other declarations
    AppearDirective
  ],
  // Imports and stuff
  1. Somewhere in the class do:
hasAppeared : boolean = false;
onAppear(){
    this.hasAppeared = true;
    console.log("I have appeared!");   // This is a good idea for debugging
  }
  1. Finally, in the HTML add the two following:
(appear)="onAppear()" *ngIf="hasAppeared" 

You can check this is working by checking the console for the message "I have appeared!".


This one is fun. The solution, as with most things angular 2, is observables.

  getTargetElementRef(currentYPos: int): ElementRef {
      // you need to figure out how this works
      // I can't comment much on it without knowing more about the page
      // but you inject the host ElementRef in the component / directive constructor and use normal vanillaJS functions to find other elements
  }
  //capture the scroll event and pass to a function that triggers your own event for clarity and so you can manually trigger
  scrollToSource: Subject<int> = new Subject<int>();
  @HostListener("window:scroll", ['$event'])
  onWindowScroll($event: any): void {
    var target = getTargetElementRef(window.pageYOffset);
    this.scrollTo(target);
  }

  scrollTo(target: ElementRef): void {
     // this assumes you're passing in an ElementRef, it may or may not be appropriate, you can pass them to functions in templates with template variable syntax such as: <div #targetDiv>Scroll Target</div> <button (click)="scrollTo(targetDiv)">Click To Scroll</button>
     this.scrollToSource.next(target.nativeElement.offsetTop);
  }

  //switch map takes the last value emitted by an observable sequence, in this case, the user's latest scroll position, and transforms it into a new observable stream
  this.scrollToSource.switchMap(targetYPos => {
       return Observable.interval(100) //interval just creates an observable stream corresponding to time, this emits every 1/10th of a second. This can be fixed or make it dynamic depending on the distance to scroll
           .scan((acc, curr) =>  acc + 5, window.pageYOffset) // scan takes all values from an emitted observable stream and accumulates them, here you're taking the current position, adding a scroll step (fixed at 5, though this could also be dynamic), and then so on, its like a for loop with +=, but you emit every value to the next operator which scrolls, the second argument is the start position
           .do(position => window.scrollTo(0, position)) /// here is where you scroll with the results from scan
           .takeWhile(val => val < targetYPos); // stop when you get to the target
  }).subscribe(); //don't forget!

With a click this is easy to use. You just bind scrollTo to a click

This only works for scrolling in one direction, However this should get you started. You can make scan smarter so it subtracts if you need to go up, and instead use a function inside takeWhile that figures out the correct termination condition based on if going up or down.

edit: rxjs 5+ compatible version

  this.scrollToSource.pipe(switchMap(targetYPos => 
       interval(100).pipe( //interval just creates an observable stream corresponding to time, this emits every 1/10th of a second. This can be fixed or make it dynamic depending on the distance to scroll
           scan((acc, curr) =>  acc + 5, window.pageYOffset), // scan takes all values from an emitted observable stream and accumulates them, here you're taking the current position, adding a scroll step (fixed at 5, though this could also be dynamic), and then so on, its like a for loop with +=, but you emit every value to the next operator which scrolls, the second argument is the start position
           takeWhile(val => val < targetYPos)) // stop when you get to the target
  )).subscribe(position => window.scrollTo(0, position)); // here is where you scroll with the results from scan

You can also use the CSS property scroll-behavior: smooth

in combination with

var yPosition = 1000;
window.scrollTo(0,yPosition)

Ref: developer.mozilla.org/docs/Web/CSS/scroll-behavior