In this short series, we’ll use Angular to recreate the popular LinkedIn game Crossclimb, a fun word puzzle that’s perfect for showing off the power of Signals in modern Angular.
This project is not only a great coding exercise, but also an enjoyable way to explore reactive state management using Signals.
So, fire up your terminal, create a new Angular app, and let’s build together! 🚀
How the game works
If you’ve never played Crossclimb before, here’s a quick rundown:
The goal is to guess seven words using a set of hints to form a connected word chain. Each consecutive pair of words differs by exactly one character, for example, “code” → “cove” → “love”.
You’ll start by completing and correctly arranging the five middle words. Once that’s done, the first and last words unlock. They come with a hint to help you guess them, and they’re connected by a logical relationship.
When all seven words are correctly guessed, you’ve solved the puzzle! 🎉
The Word component
Feel free to use your favorite font for the project: for my demo, I've used Roboto, so I wrote this in my global styles:
@import url('https://fonts.googleapis.com/css2?family=Roboto:ital,wght@0,100..900;1,100..900&display=swap');
body, * {
font-family: "Roboto", sans-serif;
font-optical-sizing: auto;
font-style: normal;
font-variation-settings: "wdth" 100;
}
First, create the main component, called Word, in a file (eg. /components/word.ts):
import { Component } from "@angular/core";
@Component({
selector: 'app-word',
imports: [],
template: `
`,
styles: ``
})
export class Word {
}
On the surface, this component is pretty simple: it's a box containing multiple <input> elements in a row (one for each letter).
However, there are some behaviors we need to implement, for example:
- Each
<input>must contain a single letter (or none) - If the user writes a second letter, it must replace the previous one
- After the user enters a letter, we must automatically focus the next
<input>, so that the user can type the whole word easily - We must display a bottom border where if there is no letter, to indicate that it's missing
This is an example of how we'll use this component:
<app-word
[letters]="[' ', ' ', ' ', ' ']"
[isReadonly]="false"
[isFinal]="false"
[isLocked]="false"
(lettersChange)="..."
(focusedChange)="..."
(lastKeydown)="..."
/>
So, we need 4 inputs:
-
letterswill be an array of characters (empty character for a missing letter) -
isReadonlywill stop the user from changing the letters (we'll set it totrueonce the user is done with the middle words) -
isFinalwill be used for the first and last words (just to style them with a different color) -
isLockedwill display a lock icon on top of the word (just for the first and last words, initially)
And two outputs:
-
lettersChangewill give us back a new array of letters when the user changes one of them -
focusedChangewill tell us the index of the character which is currently focused by the user, we need this in order to display the correct hint. We don't actually need the index (we just need to know which word it is) but it may come in handy. -
lastKeydownwill be emitted when the user has typed something on the last<input>, we'll use this to focus on the next word automatically.
Let's write them, starting with the inputs.
export class Word {
isReadonly = input(false);
isFinal = input(false);
isLocked = input(false);
letters = model.required<string[]>();
focused = model<number | null>(null);
}
letters and focused are actually models, a particular type of input which can also be overridden by the component itself. Also, each model automatically creates an output with the same name but with the Change suffix: this means we get lettersChange and focusedChange for free! They'll emit the new value once the model value changes.
Then, create the missing output:
lastKeydown = output();
The template
It's time to display something! This will be the template for our component:
<div class="container" [class.final]="isFinal()">
@for (_ of [].constructor(letters().length); let i = $index; track i) {
@let letter = letters()[i];
<input
#input
type="text"
[class.with-border]="letter === ' ' && !isReadonly() && !isLocked()"
[class.highlighted]="focused() === i && !isReadonly() && !isLocked()"
[value]="letter === ' ' ? '' : letter"
[readonly]="isReadonly()"
(keydown)="onKeydown(i, $event)"
(focus)="focused.set(i)"
(blur)="focused.set(null)"
>
}
@if (isLocked()) {
<div class="overlay">
<!--<app-icon icon="lock" />-->
</div>
}
</div>
Here's what's going on...
- The whole template is wrapped inside a
.containerelement, with an optional classfinalwhich will simply change its background color. - We're creating an empty array with a trick (
[].constructor(letters().length)) to be able to use@forto display the correct number of<input>s. - We're extracting each letter inside a template variable (
@let letter). - We're using a reference variable
#inputto be able to grab the elements later. - We're applying some dynamic classes on the elements, just for styling.
- We're binding properties and listening to events to change our states.
- We're adding an overlay to display a lock icon on top of the element while the component is still locked (initially, the first and last word).
There are a few things missing: the keydown event handler, an <app-icon> component, and some styling.
First, let's create an empty handler just to make it work:
onKeydown(index: number, e: KeyboardEvent) {}
Then, apply some styling to our component. Feel free to use these styles:
.container {
position: relative;
display: flex;
justify-content: space-evenly;
background: lightgrey;
border-bottom: 2px solid grey;
border-radius: 4px;
padding: 10px;
}
input {
border: none;
background: transparent;
width: 1.5em;
text-align: center;
outline: none;
padding: 0;
font-weight: bold;
}
.with-border {
border-bottom: 2px solid grey;
}
.highlighted {
border-bottom: 2px solid black;
}
.final {
background: #FFCBA4;
}
.overlay {
position: absolute;
inset: 0;
background: transparent;
align-items: center;
justify-content: center;
display: flex;
}
Feel free to test this component! Just place it in your app:
@Component({
selector: 'app-root',
template: `
<app-word [letters]="[' ', ' ', ' ', ' ']" />
`,
imports: [Word]
})
export class App {
}
And you should see something like this:
We just need to apply the correct logic when the user enters a letter: let's do it!
Handling the keydown event
We'll need the ability to focus on a specific <input>, and in order to do that we must grab a reference to them!
We've already applied an #input reference variable on our template, so we're able to grab all our <input> elements with the help of viewChildren:
import { ..., viewChildren } from "@angular/core";
// ...
// Inside the component's class
inputs = viewChildren<ElementRef<HTMLInputElement>>('input');
This will give us a Signal which contains an array of ElementRefs, with all of our native elements inside.
Create a focus method that you'll use to focus on the correct element:
focus(index: number) {
this.inputs()[index]?.nativeElement.focus();
}
Now, it's time to implement the onKeydown() method! So position your cursor inside of it and write the following code... Feel free to test each step on your own!
First, if the word is readonly, we must do nothing and stop the event, so that nothing is written inside our element:
if (this.isReadonly()) {
e.preventDefault();
return;
}
If the user takes advantage of the tab key to navigate the <input>s, allow it (don't prevent the event), but do nothing on the element.
if(e.key === 'Tab') {
return;
}
Then, the user could delete a character by using backspace or delete. We'll also add a simple check, so that if backspace is used on an empty character, we'll focus the previous <input>, to make it easier for the user.
if (e.key === 'Backspace' || e.key === 'Delete') {
if (e.key === 'Backspace' && this.letters()[index] === ' ') {
this.focus(index - 1);
}
this.letters.update(letters => letters.map((l, i) => index !== i ? l : ' '));
}
At this point, stop the event so that nothing is written inside the input. We'll add the character ourself in our state, and Angular will automatically replace in on the DOM thanks to our [value] binding!
e.preventDefault();
Finally, we need to mutate our state with the new letter:
if (isLetter && letter !== previousLetter) {
this.letters.update(letters => letters.map((l, i) => index !== i ? l : letter));
if (index === this.letters().length - 1) {
this.lastKeydown.emit();
} else {
this.focus(index + 1);
}
}
Notice that we're also focusing on the next <input>, and if there is none, we're just emitting the lastKeydown event. We'll use it later in another component!
Our main component is done! Feel free to test it out!
Here's the entire onKeydown() method:
onKeydown(index: number, e: KeyboardEvent) {
if (this.isReadonly()) {
e.preventDefault();
return;
}
if(e.key === 'Tab') {
return;
}
if (e.key === 'Backspace' || e.key === 'Delete') {
if (e.key === 'Backspace' && this.letters()[index] === ' ') {
this.focus(index - 1);
}
this.letters.update(letters => letters.map((l, i) => index !== i ? l : ' '));
}
e.preventDefault();
const isLetter = /^[a-zA-Z]$/.test(e.key);
const letter = e.key.toUpperCase();
const previousLetter = this.letters().at(index)!.toUpperCase();
if (isLetter && letter !== previousLetter) {
this.letters.update(letters => letters.map((l, i) => index !== i ? l : letter));
if (index === this.letters().length - 1) {
this.lastKeydown.emit();
} else {
this.focus(index + 1);
}
}
}
And this is what you should see:
The Icon component
We need an Icon component to display a couple of icons: one with a lock, and one with some bars (for the drag & drop functionality which we'll implement later). For the purpose of this exercise, you can just use the SVGs from Font Awesome, like this:
import { Component, input } from "@angular/core";
@Component({
selector: 'app-icon',
template: `
@if (icon() === 'lock') {
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512"><!--!Font Awesome Free v7.1.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2025 Fonticons, Inc.--><path d="M128 96l0 64 128 0 0-64c0-35.3-28.7-64-64-64s-64 28.7-64 64zM64 160l0-64C64 25.3 121.3-32 192-32S320 25.3 320 96l0 64c35.3 0 64 28.7 64 64l0 224c0 35.3-28.7 64-64 64L64 512c-35.3 0-64-28.7-64-64L0 224c0-35.3 28.7-64 64-64z"/></svg>
} @else {
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><!--!Font Awesome Free v7.1.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2025 Fonticons, Inc.--><path d="M0 96C0 78.3 14.3 64 32 64l384 0c17.7 0 32 14.3 32 32s-14.3 32-32 32L32 128C14.3 128 0 113.7 0 96zM0 256c0-17.7 14.3-32 32-32l384 0c17.7 0 32 14.3 32 32s-14.3 32-32 32L32 288c-17.7 0-32-14.3-32-32zM448 416c0 17.7-14.3 32-32 32L32 448c-17.7 0-32-14.3-32-32s14.3-32 32-32l384 0c17.7 0 32 14.3 32 32z"/></svg>
}
`,
styles: `
svg {
height: 1rem;
}
`
})
export class Icon {
icon = input.required<'lock' | 'bars'>();
}
Now, just remove the comment from the Word component template, and add Icon to the imports array:
import { Icon } from "./icon";
@Component({
selector: 'app-word',
imports: [Icon], // Add this
template: `
<!-- Change this: --->
<!--<app-icon icon="lock" />-->
<!-- To this: -->
<app-icon icon="lock" />
`,
...
})
Try it out! Set isLocked to true and you should see something like this:
<app-word [letters]="[' ', ' ', ' ', ' ']" [isLocked]="true" />
Next steps
In the next articles of this series we'll implement the game logic and a drag & drop list using the Angular CDK. Stay tuned!
[Continues tomorrow!]



Top comments (0)