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
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:
- Repo: https://github.com/railsstudent/ng-rxjs-30/tree/main/projects/day16-mouse-move
- Live demo: https://railsstudent.github.io/ng-rxjs-30/day16-mouse-move/
- Wes Bos’s JavaScript 30 Challenge: https://github.com/wesbos/JavaScript30