Just Another Angular CDK Feature Nobody Talks About
Your UI treats every user exactly the same, and it might be driving them crazy. Keyboard users get identical experiences to mouse users. Code programmatically focuses elements and triggers the same aggressive responses as intentional user actions. The Angular CDK's Focus Monitor can detect exactly how users interact with any element: mouse clicks, keyboard navigation, touch, or programmatic focus. And I’ll bet 90% of Angular developers have never heard of it.
Today, I’ll show you how this one service creates adaptive user experiences that respond intelligently to different interaction patterns. Let’s dive in!
The Problem Our Users Are Facing
Alright, let’s start by exposing the problem. I’ve got what looks like a simple email input field:

But watch what happens when we interact with it in different ways:

When we click into the field and then blur, an error message appears.
That seems reasonable for a mouse user, right?
Then, when we tab into the field using the keyboard and blur, the error shows up immediately again.
But then things get problematic.
When we click this “Focus Email” button, it programmatically focuses the input field and that error message is still staring at us!
This might seem fine, but it could actually be a problem.
Think about it, if our code is automatically focusing this field, maybe as part of a form validation flow or user guidance, we probably don’t want to spam the user with error messages.
That’s just a bad user experience.
What we really want here is smart validation that behaves differently based on how the user focused the field.
And that’s exactly what the Angular CDK Focus Monitor can help us achieve.
What We’re Working With
Let’s look to our component HTML to see what we’re dealing with.
Looking at this template, it’s pretty standard stuff.
On our input element we have a template reference variable called “emailInput”, we’ll need this in a moment:
<input #emailInput ... />
We have a blur event that calls an onBlur()
method:
<input (blur)="onBlur()" ... />
This is our current validation trigger, and it’s the source of our one-size-fits-all problem.
The input uses ngModel to bind to an “email” signal, which is how we’re storing the value the user types:
<input [ngModel]="email" ... />
This input also gets an “error” class when a “showError” signal is true, which provides visual feedback:
<input [class.error]="showError()" ... />
Below the input, we have our error message that only shows when the “showError” signal is true, that’s this @if block:
@if (showError()) {
<div class="error">
Invalid email
</div>
}
And finally, there’s the button that focuses the input using that template reference variable simulating what your application might do to guide users:
<button (click)="emailInput.focus()">
Focus Email
</button>
Now let’s switch to the component TypeScript to see the logic behind this.
First, we have the “showError” signal initialized to false… no errors by default:
protected showError = signal(false);
Next, we have the “email” signal initialized to an empty string:
protected email = signal('');
Remember, that’s where we store what the user types.
Then we have the onBlur()
method:
protected onBlur() {
if (this.hasInvalidEmail()) {
this.showError.set(true);
}
}
It’s pretty straightforward.
It checks if we have an invalid email using this helper method:
private hasInvalidEmail(): boolean {
return !this.email().includes('@');
}
If it is invalid, it sets the “showError” signal to true.
This validation itself is super simple, it’s just looking for an @ symbol.
Obviously you’d want something more robust in production, but this works for our demo.
The problem is this function has no idea how the user focused the field.
Mouse click? Keyboard navigation? Touch gesture? Programmatic focus? It treats them all identically.
The Solution That Changes Everything
So here’s what we want to achieve instead: when a user tabs into the field with their keyboard or clicks with their mouse, and then leaves the field, if it’s invalid, we want to show the error message.
If it’s valid, we want to make sure the error is hidden.
But here’s the key difference, if our code programmatically focuses the field, like when someone clicks that “Focus Email” button, we don’t want to show any error messages.
In fact, if the error message is already showing, we want to hide it.
This is where the Angular CDK Focus Monitor service comes into play.
It can tell us exactly how an element received focus, which lets us create smarter, more user-friendly validation.
Building Something Better
First, a quick note, you’ll need the Angular CDK installed.
You’ll just need to run this command in your project root to install it:
npm install @angular/cdk
Okay, I’m going to start by injecting the FocusMonitor
service from the CDK A11y module using Angular’s inject function:
import { FocusMonitor } from '@angular/cdk/a11y';
...
protected focusMonitor = inject(FocusMonitor);
Next, let’s add a constructor with an effect:
import { ..., effect } from "@angular/core";
...
constructor() {
effect(() => {
});
}
Within this effect, we can use the FocusMonitor
service to monitor the focus state with the monitor()
function.
This monitor method needs an ElementRef to monitor focus on, in our case that’s our email input.
So, we’ll use Angular’s new viewChild() signal function to get a reference to it:
import { ..., viewChild } from "@angular/core";
...
private emailInput = viewChild.required<ElementRef>('emailInput');
This creates a signal that will hold a reference to the element with the template reference variable “emailInput”.
Then we can pass this element to our monitor()
function:
constructor() {
effect(() => {
this.focusMonitor
.monitor(this.emailInput())
});
}
This method returns an observable, so we need to subscribe to it.
But first, let’s add proper cleanup using takeUntilDestroyed().
We’ll also need to inject DestroyRef for this to work and then pass it to the takeUntilDestroyed()
function:
import { ..., DestroyRef } from "@angular/core";
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
...
private destroyRef = inject(DestroyRef);
constructor() {
effect(() => {
this.focusMonitor
.monitor(this.emailInput());
.pipe(takeUntilDestroyed(this.destroyRef))
});
}
Ok, now we’re ready to subscribe:
constructor() {
effect(() => {
this.focusMonitor
.monitor(this.emailInput());
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(origin => {
});
});
}
Now, let me explain what this observable gives us.
The result will be a FocusOrigin.
This observable fires every time focus on the element changes.
When the element gets focused, we’ll receive "mouse"
for mouse clicks, "keyboard"
for tab navigation or arrow key movements, "touch"
for touch events on mobile devices, or "program"
when our code focuses it programmatically.
When the element loses focus, we get null
.
What we want to do in this example is track the last origin state for some of our logic.
Let’s add a new signal for this using the FocusOrigin
interface for this signal since that’s what we get from the observable:
import { ..., FocusOrigin } from '@angular/cdk/a11y';
...
protected lastFocusOrigin = signal<FocusOrigin>(null);
Now, let’s set this signal in our subscription:
constructor() {
effect(() => {
this.focusMonitor
.monitor(this.emailInput());
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(origin =>
this.lastFocusOrigin.set(origin));
});
}
Also, let’s temporarily comment out the onBlur()
method so we can see the focus monitor in action:
protected onBlur() {
// if (this.hasInvalidEmail()) {
// this.showError.set(true);
// }
}
The Magic Revealed
Let’s switch to the HTML and add a way to see what’s happening behind the scenes.
To do this let’s add a paragraph that shows the current focus origin:
<p>
Focus Origin: {{ lastFocusOrigin() ?? 'none' }}
</p>
If it’s null, we’ll display “none” to make it crystal clear.
Okay let’s save and see this in action:

Perfect! When I click into the field, it shows "mouse"
as the focus origin.
When I blur, it shows "none"
because focus was lost.
When I tab into the field, it shows "keyboard"
.
This tells us the user is navigating intentionally.
And when I click the “Focus Email” button, we get "program"
because we focused it programmatically.
This is incredible information.
We now know exactly how users are interacting with our interface, and we can create experiences tailored to their interaction style.
Creating Adaptive Experiences
Now let’s use this focus origin information to create our smart validation.
Let’s switch back to the component TypeScript and in our subscription, let’s add some adaptive logic.
First, let me create some readable constants.
One for keyboard interactions, and another for mouse interactions:
const keyboardError = this.lastFocusOrigin() === 'keyboard';
const mouseError = this.lastFocusOrigin() === 'mouse';
Now here’s the adaptive part, if the user focused with keyboard or mouse, we want to provide validation feedback when focus changes.
Let’s set showError()
based on whether the email is actually invalid. True for invalid, false for valid:
if (keyboardError || mouseError) {
this.showError.set(this.hasInvalidEmail());
}
Then, if the origin is "program"
, meaning our application focused it, we hide any error messages:
if (origin === 'program') {
this.showError.set(false);
}
Here’s what the complete final code looks like:
constructor() {
effect(() => {
this.focusMonitor
.monitor(this.emailInput());
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(origin => {
const keyboardError = this.lastFocusOrigin() === 'keyboard';
const mouseError = this.lastFocusOrigin() === 'mouse';
if (keyboardError || mouseError) {
this.showError.set(this.hasInvalidEmail());
}
if (origin === 'program') {
this.showError.set(false);
}
this.lastFocusOrigin.set(origin);
});
});
}
And we can now completely remove that old onBlur()
method since we’re handling everything in our adaptive focus monitor.
Let’s save and see our adaptive interface in action:

Now when we click into the field and type an invalid email and then click outside, the error appears because this was a mouse interaction. Perfect!
Then when we click back in and make it valid and blur again, the error disappears because the email is now valid.
With keyboard navigation, if we tab to the field and make it invalid again and then tab away, the error shows up because this was a keyboard interaction.
When we shift-tab back, and fix the email to make it valid, then tab away, the error disappears because the email is valid.
Then for the moment of truth, when we make it invalid again and blur to show the error message and then click the “Focus Email” button, the error message vanishes!
That’s our programmatic focus logic working perfectly.
No more interrupting user workflows when our application is trying to be helpful.
Final Thoughts: Put It to Work
And there you have it, the Angular CDK Focus Monitor in action!
This tiny but powerful service gives you incredible insight into how users interact with your UI.
Think about all the possibilities this opens up:
- Different validation timing for keyboard vs mouse users
- Better accessibility for screen reader users
- Preventing validation spam during programmatic focus
- Creating more intuitive user experiences, and more!
The best part? Your users will never even notice the difference.
Things will just work like they’re expecting them to.
The Focus Monitor is just one of many hidden gems in the Angular CDK.
If you want to see more lesser-known Angular features that can level up your applications, don’t forget to subscribe and check out my other Angular tutorials for more tips and tricks!
Additional Resources
- Angular CDK Focus Monitor
- Angular CDK A11y Module
- Web Accessibility Guidelines
- Angular CDK Installation Guide
- My course “Angular: Styling Applications”
Want to See It in Action?
Want to experiment with the final version? Explore the full StackBlitz demo below. If you have any questions or thoughts, don’t hesitate to leave a comment.