Signaling Angular Animations

Alejandro Cuba Ruiz
ngconf
Published in
6 min readMay 23, 2023

--

Screenshot of animated table cells, taken after solving the word search from the code example

The new reactivity model added to the Angular public API as a "Developer Preview" in version 16 has triggered a sense of urgency to reassess our traditional approaches to change detection and state management to accommodate it into our programming practice.

After the RFC discussions, Angular Signals is here to stay. On the journey to full stability in version 17, this feature may also impact how we manage animations in Angular.

Animations meet Signals in a word search puzzle

The integration of both core features, for instance, can significantly enhance rendering performance, mainly when Angular animations are dependent on reactive parametrized state values. Angular Signals come in handy when we need to trigger animations in response to a state change at the component or application level.

To experiment with this, let's create a simple word search in a standalone Angular component. The goal is to animate the individual squares when the puzzle is finished, so having a single winning letter sequence will get us straight to the board completion.

As you read this article, feel free to clone the entire code base from the GitHub repository at https://github.com/alejandrocuba/signaling-angular-animations so you can build the Angular project on your local environment and play around with it.

Here is an excerpt of the main component code.

@Component({
selector: 'app-word-search',
standalone: true,
imports: [CommonModule]
// template and styles
)}

export class WordSearchComponent implements OnInit {
wordSearchBoard: { letter?: string, state: string }[][] = [];
wordSearchBoardSize = 7; // 7x7 board
sequence = 'SIGNALS';
sequenceIndex = 0;
sequenceCells: { row: number, column: number }[] = [];
lastClickedCell: { row: number, column: number } | null = null;
numberOfClickedCells = signal(0);
isGameFinished = signal(false);

ngOnInit(): void {
this.initializeWordSearch();
}
}

You can notice there are two signals initialized as component properties. The numberOfClickedCells signal will track the player's activity to use the resulting number as a parameter for the upcoming animations.

The sequenceIndexand lastClikedCell properties will ensure that the player can only select letters on adjacent cells. As a rule, we will allow selecting a letter from the sequence if the currently clicked cell is adjacent to the previous correct letter.

Initializing the word search board

Let's add TypeScript code to store cell data in a 2D array and fill the board. To simplify things, we will make the word search render the “SIGNALS” sequence from the top-left edge in a 7x7 grid to match the word length.

createBoard() {
this.wordSearchBoard = Array.from({ length: this.wordSearchBoardSize }, (x, i) =>
Array.from({ length: this.wordSearchBoardSize }, (x, j) => this.initializeCell(i, j))
);
}

fillBoard(): void {
for (let i = 0; i < this.wordSearchBoardSize; i++) {
for (let j = 0; j < this.wordSearchBoardSize; j++) {
this.wordSearchBoard[i][j] = this.initializeCell(i, j);
}
}
}

initializeCell(i: number, j: number): { letter?: string, state: string } {
let cell = { letter: '', state: 'default' };
if (i === j) {
cell.letter = this.sequence[i];
} else {
cell.letter = String.fromCharCode(65 + Math.floor(Math.random() * 26));
}
return cell;
}

The following template logic iterates over the array to create a tabular HTML structure rendering a grid of random letters per square.

<article>
<table class="board">
<tr *ngFor="let row of wordSearchBoard; let x = index">
<td *ngFor="let cell of row; let y = index">
{{cell.letter}}
</td>
</tr>
</table>
</article>

Defining animated states

In order to set up the required browser animation capabilities from the @angular/platform-browser package, let's import the corresponding provider in the app.config.ts file.

import { ApplicationConfig, importProvidersFrom } from '@angular/core';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';

export const appConfig: ApplicationConfig = {
providers: [
importProvidersFrom([BrowserAnimationsModule])
]
};

From the previous initializeCell() declaration, you noticed that cells store their state information. When clicked, cells can transition from the default state to the correct (when the clicked letter is part of the sequence) or incorrect state (when the letter is not part of it).

The following animation metadata property is declared inside the component decorator.

animations: [
trigger('cellState', [
state('correct', style({
backgroundColor: 'var(--dark-old-color)',
color: 'var(--cell-background-color)'
})),
state('wrong', style({
backgroundColor: 'var(--wrong-cell-color)',
color: 'var(--cell-background-color)'
})),
transition('default <=> correct', animate(300)),
transition('default <=> wrong', animate(300)),
transition('wrong => correct', animate(600))
]),
trigger('winAnimation', [
transition('* => true', [
animate('5s', keyframes([
style({ transform: 'translateX({{x1}}%) translateY({{y1}}%)', offset: 0.2 }),
style({ transform: 'translateX({{x2}}%) translateY({{y2}}%)', offset: 0.4 }),
style({ transform: 'translateX({{x3}}%) translateY({{y3}}%)', offset: 0.6 }),
style({ transform: 'translateX({{x4}}%) translateY({{y4}}%)', offset: 0.8 }),
style({ transform: 'translateX(0) translateY(0)', offset: 1.0 }),
]))
])
])
]

We can refer to those triggers in the template and associate them with expressions that resolve to one of the set animation states.

<td
<!-- existing template logic -->
[@cellState]="{ value: cell.state }"
[@winAnimation]="randomizeCellPosition()"
(click)="checkLetter(x, y)"
>

Notice that we also added an event binding to invoking checkLetter() upon a click. The state of the cell will be based on whether the corresponding letter matches the right position in the word sequence.

checkLetter(row: number, column: number): void {
if (this.isGameFinished()) {
return;
}

this.numberOfClickedCells.update(amount => amount + 1);

if (this.isAdjacentCell(row, column) // if the cell is adjacent to the last clicked cell
&& this.wordSearchBoard[row][column].letter === this.sequence[this.sequenceIndex]) {
this.sequenceIndex++;
this.wordSearchBoard[row][column].state = 'correct';
this.lastClickedCell = { row, column };
this.sequenceCells.push({ row, column });

if (this.sequenceIndex === this.sequence.length) {
this.isGameFinished.set(true);
}
} else {
this.wordSearchBoard[row][column].state = 'wrong';
}
}

Triggering the final animation to celebrate the win

On every click, the numberOfClikedCells signal updates based on its current value. If the last letter of the sequence is clicked, the isGameFinished signal is set to true. Listening to this specific signal within the randomizeCellPosition() logic triggers the @winAnimation.

randomizeCellPosition() {
let position: Record<string, number> = {};
const maximumValue = 300;

this.randomFactor = computed(() => this.sequence.length * maximumValue / (this.numberOfClickedCells() || 1)) // ensure there's no division by zero

for (let i = 0; i < 8; i++) {
let axis = i < 4 ? 'x' : 'y';
let index = (i % 4) + 1;
position[`${axis}${index}`] = (Math.random() * 2 - 1) * this.randomFactor(); // allow negative values
}

return {
value: this.isGameFinished(),
params: position,
};
}

The randomFactor property uses a computed signal that updates its value based on numberOfClickedCells changes.

To achieve some dynamism based on the player's performance during the game, the board tiles will fly around to random points on the screen following the parametrized@winAnimation's keyframe instructions. The position's value will be inversely proportional to the number of clicks, so the fewer clicks, the larger the resulting random number will be.

Completing the correct letter sequence triggers @winAnimation

Each property in the position object can range between the values 300 and -300, interpreted as pixels during the CSS translation in the X and Y axis.

A second paragraph element in the template gives you runtime feedback on the maximum pixel quantity used in the CSS translateX and translateY animations after the optimal amount of clicks is reached.

In the provided code example in Github you can check the resulting initializeWordSearch() logic to re-initialize the component state, allowing the player to reset the game by clicking the "Restart Word Search" button.

Further animation steps

Integrating signals in a simple word search puzzle just demonstrates how seamlessly these reactive triggers blend with Angular animations. You can experiment with Animation’s trigger events in the template, which are based on a signal value, as shown in the following code block:

<td
<!-- existing template logic -->
(@winAnimation.done)="isGameFinished() && this.resetWordSearch()"
>

Imagine using signals as expressions on single or multi-style bindings. It could give you a broader canvas to paint your animation story, introducing more styling opportunities to the user interface.

<td
<!-- existing template logic -->
[style.property.px]="changingSignal()"
>
<td
<!-- existing template logic -->
[style]="styleExpressionContainingComputedSignals()"
>

Angular Signals can also simplify the process of sharing animation states across components, creating the illusion of a seemingly interconnected UI.

How do you envision the future of Animations driven by Angular Signals? I hope this article ignites your curiosity to experiment with this topic, uncovering new techniques along the way. Don’t forget to share your discoveries — your findings could inspire others and contribute to reshaping Animations in upcoming versions of Angular.

--

--

Alejandro Cuba Ruiz
ngconf

<front-end web engineer />, Angular GDE, traveler, reader, writer, human being.