Animate text shadow using RxJS and Angular

Connie Leung - Nov 27 '22 - - Dev Community

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.

Create a new Angular project in workspace

ng generate application day16-mouse-move
Enter fullscreen mode Exit fullscreen mode

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 { }
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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, 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('')
      );
  }
}
Enter fullscreen mode Exit fullscreen mode

I use VieChild decorator to obtain the

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 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. 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

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:

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