Animate text shadow using RxJS and Angular

Reading Time: 4 minutes

Loading

Introduction

This is day 16 of Wes Bos’s JavaScript 30 challenge and I am going to add CSS animation to text using RxJS and Angular. Through RxJS, I can update text shadow style of text element during mouse moves to produce the effect of CSS animation.

In this blog post, I describe how to use RxJS operators to listen to mouse move event, calculate the X and Y distances of the text shadows, apply async pipe to resolve the observable and finally assign the values to text-shadow property. As a bonus, I refactor map operators into custom RxJS operator such that the observable codes in ngOnInit is kept as lean as possible.

let's go

Create a new Angular project in workspace

ng generate application day16-mouse-move

Create Mouse Move feature module

First, we create a MouseMove feature module and import it into AppModule. The feature module is consisted of MouseMoveComponent that encapsulates the logic of CSS animation.

Then, Import MouseMoveModule in AppModule

// mouse-move.module.ts

@NgModule({
  declarations: [
    MouseMoveComponent
  ],
  imports: [
    CommonModule
  ],
  exports: [
    MouseMoveComponent
  ]
})
export class MouseMoveModule { }

// app.module.ts

import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';

import { AppComponent } from './app.component';
import { MouseMoveModule } from './mouse-move';

@NgModule({
  declarations: [
    AppComponent
  ],
  imports: [
    BrowserModule,
    MouseMoveModule
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }

Declare component in feature module

In MouseMove feature module, I declare MouseMoveComponent that derives text-shadow CSS property.

The component will apply built-in RxJS operators and the mapXYWalk operator in custom-operators directory.

src/app
├── app.component.spec.ts
├── app.component.ts
├── app.module.ts
└── mouse-move
    ├── custom-operators
    │   └── mapTextShadowStyle.operator.ts
    ├── index.ts
    ├── mouse-move
    │   ├── mouse-move.component.spec.ts
    │   └── mouse-move.component.ts
    └── mouse-move.module.ts

I define component selector, inline template and inline CSS styles in the file. RxJS codes will be implemented in the later sections of the blog post. For your information, <app-mouse-move> is the tag of MouseMoveComponent.

import { Component, OnInit, ChangeDetectionStrategy, ViewChild, ElementRef } from '@angular/core';
import { fromEvent, Observable, startWith } from 'rxjs';

@Component({
  selector: 'app-mouse-move',
  template: `
    <div class="hero" #hero>
      <ng-container *ngIf="textShadow$ | async as textShadow">
        <h1 contenteditable [style.textShadow]="textShadow">🔥WOAH!</h1>
      </ng-container>
    </div>
  `,
  styles: [`
    :host { 
      display: block;
    }

    .hero {
      min-height: 100vh;
      display: flex;
      justify-content: center;
      align-items: center;
      color: black;
    }

    h1 {
      text-shadow: 10px 10px 0 rgba(0,0,0,1);
      font-size: 100px;
    }
  `],
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class MouseMoveComponent implements OnInit {

  @ViewChild('hero', { static: true, read: ElementRef })
  hero!: ElementRef<HTMLDivElement>;

  textShadow$!: Observable<string>;
  
  ngOnInit(): void {
    const nativeElement = this.hero.nativeElement;

    this.textShadow$ = fromEvent(nativeElement, 'mousemove')
      .pipe(
        startWith('')
      );
  }
}

I use VieChild decorator to obtain the <div> element that has the hero reference. It is needed because I am going to listen to the mousemove event on this.hero.nativeElement.

The initial value of text-shadow property is an empty string; therefore, there is no CSS animation until mouse cursor moves.

Next, I delete boilerplate codes in AppComponent and render MouseMoveComponent in inline template.

import { ChangeDetectionStrategy, Component } from '@angular/core';
import { Title } from '@angular/platform-browser';

@Component({
  selector: 'app-root',
  template: '<app-mouse-move></app-mouse-move>',
  styles: [`
    :host {
      display: block;
    }
  `]
})
export class AppComponent {
  title = 'Day16 Mouse Move';

  constructor(titleService: Title) {
    titleService.setTitle(this.title);
  }
}

Write RxJS code to derive text-shadow property

In this section, I will incrementally modify textShadow$ to derive the text-shadow property of the text element.

// mouse-move.component.ts

import { filter, fromEvent, map, Observable, startWith } from 'rxjs';
import { mapTextShadowStyle } from '../custom-operators/mapTextShadowStyle.operator';

ngOnInit(): void {
    const nativeElement = this.hero.nativeElement;

    this.textShadow$ = fromEvent(nativeElement, 'mousemove')
      .pipe(
        filter(e => e instanceof MouseEvent),
        map(e => e as MouseEvent),
        mapTextShadowStyle(nativeElement),
        startWith('')
      ); 
}

Let’s explain each line of RxJS code

  • fromEvent(nativeElement, ‘mousemove’) listens to the mousemove event of the <div> element
  • filter(e => e instanceof MouseEvent) filters the MouseEvent event
  • map(e => e as MouseEvent) cast the event to MouseEvent event
  • mapTextShadowStyle(nativeElement) is a RxJS custom operator that computes the values of text-shadow property
  • startWith(”) determines the initial value of the text-shadow style

Demystify mapTextShadowStyle custom operator

mapTextShadowStyle is a function that returns a function that returns Observable<string>. The inner function accepts a source Observable and emits mousemove event to map operators to return text-shadow property values.

// mapTextShadowStyle.operator.ts

import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';

export function mapTextShadowStyle<T extends HTMLDivElement>(nativeElement: T, walk = 500) {
    return function(source: Observable<MouseEvent>): Observable<string> {
        return source.pipe(
            map((e: MouseEvent) => {
                const { offsetX: x, offsetY: y } = e;
                const evtTarget = e.target as T;
                const newOffset = { x, y };
                if (evtTarget !== nativeElement) {
                    newOffset.x = newOffset.x + evtTarget.offsetLeft;
                    newOffset.y = newOffset.y + evtTarget.offsetTop;
                }
            
                const { offsetWidth: width, offsetHeight: height } = nativeElement;
                const xWalk = Math.round((x / width * walk) - (walk / 2));
                const yWalk = Math.round((y / height * walk) - (walk / 2));
                return { xWalk, yWalk };
            }),
            map(({ xWalk, yWalk }) => 
                (`
                    ${xWalk}px ${yWalk}px 0 rgba(255,0,255,0.7),
                    ${xWalk * -1}px ${yWalk}px 0 rgba(0,255,255,0.7),
                    ${yWalk}px ${xWalk * -1}px 0 rgba(0,255,0,0.7),
                    ${yWalk * -1}px ${xWalk}px 0 rgba(0,0,255,0.7)
                `))
            );
    }
}

The first map operator computes the x and y distance between text shadows and the text element. The second map operator uses the xWalk and yWalk parameters to compute text-shadow property values and outputs from textShadow$ observable.

Finally, I have a simple page that produces animated text shadows when mouse cursor moves on the <div> element.

Final Thoughts

In this post, I show how to use RxJS and Angular to demonstrate CSS animation. When observable.pipe() becomes longer, I can refactor RxJS operators into custom operator and reuse it in pipe method. Moreover, RxJS code is declarative that I can comprehend after coming back to the codebase after a couple of days.

This is the end of the blog post and I hope you like the content and continue to follow my learning experience in Angular and other technologies.

Resources:

  1. Repo: https://github.com/railsstudent/ng-rxjs-30/tree/main/projects/day16-mouse-move
  2. Live demo: https://railsstudent.github.io/ng-rxjs-30/day16-mouse-move/
  3. Wes Bos’s JavaScript 30 Challenge: https://github.com/wesbos/JavaScript30