I Built a “Cooldown” Button in Angular… Here’s How

June 05, 2025 | 16 Minute Read

Let’s be real, few things in UX are as annoying as a trigger-happy button that just keeps firing. Whether it's a feedback form or a password reset request, sometimes... a button just needs to chill. So in this post, we’re building a smart “cooldown” button in Angular. One that disables itself after each click, shows a countdown, and prevents those frantic double submits. All with no external dependencies, just modern Angular features like signals and good ol’ setInterval().

It’s a clean pattern you can reuse across your app, and we’ll walk through the whole thing step-by-step.

Why a “Cooldown” Button?

Think about feedback forms, one-time password (OTP) requests, or “Resend Email” buttons. You don’t want the user clicking repeatedly and hammering your backend. A “cooldown” button provides:

  • Click-throttling
  • Visual feedback for the user
  • Improved UX and backend safety

And it’s surprisingly easy to build in Angular.

Kick Things Off with a Basic Angular Form

Here’s our starting point, a basic feedback form with a submit button and a success message:

Example of a basic feedback form with a submit button and a success message in Angular

When we look at the template for this form component, we can see that the button is actually already set up as its own “cooldown” button component:

<h2>Send us a message</h2>
<label for="message">Message</label>
<textarea></textarea>

<app-cooldown-button
    [disabled]="!message()"
    (click)="onSubmit($event)">
</app-cooldown-button>

@if (showSuccess()) {
    <p>✅ Message sent successfully!</p>
}

So, let’s look at the template for this component:

<button 
    (click)="handleClick()" 
    [disabled]="disabled()">
    Send
</button>

It’s really simple, it’s just a button that has a click event and that’s disabled if the “disabled” input is true.

Now let’s look at the TypeScript:

import { Component, input, ChangeDetectionStrategy } from '@angular/core';
import { CommonModule } from '@angular/common';

@Component({
    selector: 'app-cooldown-button',
    templateUrl: './cooldown-button.html',
    styleUrl: './cooldown-button.scss',
    imports: [CommonModule],
    changeDetection: ChangeDetectionStrategy.OnPush
})
export class CooldownButtonComponent {
    readonly disabled = input(false);

    protected handleClick() {
    }
}

This too is super simple. We just have the “disabled” input and the empty handleClick() method.

So, this all is nothing fancy yet.

The goal is to give that button a brain, to have it control itself and provide visual feedback to the user.

Let’s Give This Button a Brain

Let’s add some logic for this button to disable itself and show a countdown for a few seconds after it’s clicked.

First, let’s add a few properties to manage the state of this button:

import { ..., signal } from '@angular/core';

export class CooldownButtonComponent {
    protected cooldownSeconds = signal(0);
    private intervalId!: number;
    private duration = signal(6);
}

The “cooldownSeconds” signal is used to display the remaining seconds before the button is clickable again.

The “intervalId” is used to store the ID of the interval that’s used with the setInterval() function.

When using setInterval(), it returns a positive integer that uniquely identifies the interval, and can be used to clear the interval using the clearInterval() function.

The “duration” signal is used to set the duration of the “cooldown” in seconds for calculations.

Now, let’s update the “handleClick()” handle the “cooldown” logic.

First, let’s add a variable to track the end of the timer in terms of seconds:

protected handleClick() {
    const end = Date.now() + this.duration() * 1000;
}

We’re using the current time and the “duration” signal, multiplied by one thousand since we’re dealing with milliseconds and we want to display seconds in the end.

Now we want to make sure we clear any existing running intervals so we’ll use the clearInterval() function and we’ll pass it our “intervalId”.

protected handleClick() {
    ...
    this.clearInterval();
}

This ensures that if an interval is already running, we clear it before starting a new one, preventing overlapping countdowns.

Now we’re ready to add our interval.

Since setInterval() returns a positive integer, we can use it to store the interval id in our “intervalId” variable:

protected handleClick() {
    ...
    this.intervalId = setInterval(() => {
    }, 1000);
}

Okay so this timer is going to run every second until we clear it.

So within it, let’s add a variable for the remaining time left in our counter:

protected handleClick() {
    ...
    this.intervalId = setInterval(() => {
        const remaining = Math.ceil((end - Date.now()) / 1000);
    }, 1000);
}

We’re using Math.ceil() to make sure that we always round up to the nearest second.

We’re also using the “end” variable we calculated earlier, subtracting the current time, and dividing by one thousand to get the remaining time in seconds.

Now, let’s update the “cooldownSeconds” signal to display this remaining time:

protected handleClick() {
    ...
    this.intervalId = setInterval(() => {
        ...
        this.cooldownSeconds.set(Math.max(0, remaining));
    }, 1000);
}

For this we’re using the Math.max() function to make sure that we don’t end up with a negative number of seconds by using zero along with our remaining count.

This will always take the larger of the two numbers.

It just prevents issues in case the timer overshoots slightly.

Now, we can check to see if our remaining duration is less than or equal to zero, and if it is, we’ll clear the interval and reset the button:

protected handleClick() {
    ...
    this.intervalId = setInterval(() => {
        ...
        if (remaining <= 0) {
            clearInterval(this.intervalId);
        }
    }, 1000);
}

This will kill the timer when we reach the end of our countdown.

Okay, now the last part here is to make sure the interval is cleared when the component is destroyed.

So, let’s implement the OnDestroy interface:

import { ..., OnDestroy } from '@angular/core';

export class CooldownButtonComponent implements OnDestroy { 
    ...
}

Then we can simply add the ngOnDestroy() method and clear the interval:

ngOnDestroy() {
    clearInterval(this.intervalId);
}

This just ensures that we don’t leave any timers running after the component is gone.

So the final code for this component looks like this:

import { Component, signal, OnDestroy, input, ChangeDetectionStrategy } from '@angular/core';
import { CommonModule } from '@angular/common';

@Component({
  selector: 'app-cooldown-button',
  templateUrl: './cooldown-button.html',
  styleUrl: './cooldown-button.scss',
  imports: [CommonModule],
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class CooldownButtonComponent implements OnDestroy {
  readonly disabled = input(false);
  protected cooldownSeconds = signal(0);
  private intervalId!: number;
  private duration = signal(6);

  protected handleClick() {
    const end = Date.now() + this.duration() * 1000;
    clearInterval(this.intervalId);

    this.intervalId = setInterval(() => {
        const remaining = Math.ceil((end - Date.now()) / 1000);
        this.cooldownSeconds.set(Math.max(0, remaining));

        if (remaining <= 0) {
            clearInterval(this.intervalId);
        }
    }, 1000);
  }

  ngOnDestroy() {
    clearInterval(this.intervalId);
  }
}

Show the Countdown in the UI

Now, let’s update this button label in the template.

We’ll add a condition to check if the “cooldown seconds” value is greater than zero.

If it is, we’ll add a message that displays our live countdown in seconds as it’s running.

And then we’ll move the current “Send” label to the else:

<button 
    (click)="handleClick()" 
    [disabled]="disabled()">
    @if (cooldownSeconds() > 0) {
        {{ `Retry in ${cooldownSeconds()}s` }}
    } @else {
        Send
    } 
</button>

Ok, I think that’s everything now, so let’s save and see how this all works:

Example of a cooldown button with a countdown in Angular

Now, when we submit, we have a count down!

Pretty cool huh?

But there is a portion of this countdown where the button is still enabled even though the timer is counting down.

Let’s fix this.

Lock the Button Until the Countdown Ends

Let’s make sure the button is disabled when it’s in “cooldown” mode no matter what.

To do this, let’s add to our current disabled attribute binding to also disable when the “cooldown seconds” signal is greater than zero:

<button 
    (click)="handleClick()" 
    [disabled]="disabled() || cooldownSeconds() > 0">
    ...
</button>

Okay that should do it, let’s save and try this out again now:

Example of a cooldown button disabled for the entire duration of the countdown in Angular

Nice! Now when we submit, the button is disabled until the countdown ends.

So, this is working pretty well now, but since it’s reusable, we may find that we want to change the label.

What if we wanted this button to say “send message” instead of “send”?

Well, we can’t do this currently.

Make the Button Text Customizable

So, what we should do is add an input for the button label.

Back in the TypeScript, let’s add a new “label” input:

export class CooldownButtonComponent implements OnDestroy {
    ...
    readonly label = input('Send');
}

Here, we’re providing a fallback value of “Send”, so if we don’t pass a custom label, it’ll still display “Send” like it does currently.

Now we just need to update the template to use this new input:

<button 
    (click)="handleClick()" 
    [disabled]="disabled()">
    @if (cooldownSeconds() > 0) {
        {{ `Retry in ${cooldownSeconds()}s` }}
    } @else {
        {{ label() }}
    } 
</button>

Then, we just need to update the usage of this component in the form to pass in a custom “Send Message” label:

<app-cooldown-button
    label="Send Message"
    [disabled]="!message()"
    (click)="onSubmit($event)">
</app-cooldown-button>

Okay, now let’s save and check it out:

Example of a cooldown button with a custom label using a signal input in Angular

There, now we have a custom label.

I think that’s a good addition to this component because it can be used for pretty much anything.

Now what about the duration?

Make the “Cooldown” Duration Configurable

What if we want the ability to configure the duration?

Like, what if we wanted this particular button to countdown from eight seconds instead of six?

Well, let’s switch our current “duration” signal to an input instead:

export class CooldownButtonComponent implements OnDestroy {
    ...
    readonly duration = input.required<number>();
}

In this case we’re also making it required, which means we’ll have to add a duration every time we use this component.

So let’s switch back to the form component and update the usage of this component to pass in a custom duration:

<app-cooldown-button
    label="Send Message"
    [duration]="8"
    [disabled]="!message()"
    (click)="onSubmit($event)">
</app-cooldown-button>

Okay, that’s all we need for this so let’s save and see how it works now:

Example of a cooldown button with a custom duration using a signal input in Angular

Nice, now when we submit, we have a countdown from eight seconds instead of six.

Final Thoughts: A Smart Button with Boundaries

What started as a basic button became a self-managing Angular component that:

  • Disables itself after each click
  • Shows a live countdown using signals
  • Supports configurable labels and durations
  • Plays nicely in any form with zero external services

It’s a small enhancement that delivers a big UX payoff, and a reusable pattern that scales.

If this helped you, consider subscribing, checking out more Angular tutorials, or grabbing some dev-friendly merch from my shop!

Additional Resources

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.