Cooldown Button - National Design System

A button behavior that runs a live countdown before re-enabling, for rate-limiting resend, retry, and any action you do not want repeated rapidly.

Resend with Loading State

The full featured pattern for OTP, verification email, and password reset flows. The page stamps the loading state for as long as its request actually runs, while the button counts down underneath and comes back with a different label

Resend Code
<button type="button" class="nds-btn nds-primary nds-cooldown" id="cooldown-demo-resend" data-cooldown="15" data-cooldown-label="Resend in {s}s" data-resend-label="Resend"> <span class="nds-label">Send code</span> </button>

Simple Cooldown

For rate-limited retry buttons where you just need to prevent rapid repeats without a confirmation step

Retry
<button type="button" class="nds-btn nds-secondary nds-cooldown" data-cooldown="10" data-cooldown-label="Try again in {s}s"> <span class="nds-label">Try again</span> </button>

Built-in Features

Auto-initialization

Activates on every .nds-cooldown on the page and on any element added later. No wiring code required.

Loading State Is Yours

The component owns no loading phase, because a fixed timer cannot know how long a response takes. Stamp data-state="loading" from nds:cooldown:triggered and clear it when the response lands. The state hides the label, so the countdown ticks under the spinner.

Live Countdown Label

Swaps the button label to your template every second, with every {s} replaced by the seconds remaining, until the cooldown ends.

Confirmation You Control

The component fires no toast of its own. Issue your request from nds:cooldown:triggered and confirm from the response, so a failed send never reports success.

Post-send Label Swap

After the first completed cycle the button can show a different label (for example "Send code" becomes "Resend").

Programmatic Control

Trigger the cycle from JS, abort a cooldown in flight, and hook four lifecycle events to wire your own side effects around the built-in behavior.

Usage Guidelines

Best Practices

  • Use for resend flows where the backend imposes a per-user rate limit (OTP, verification email, password reset) and you want the UI to match that limit exactly
  • Use for retry buttons after a failed request, to stop users from hammering an endpoint that is already struggling
  • Show loading with the button's own data-state="loading": add it from nds:cooldown:triggered and remove it when the response lands. Never model the wait with a fixed timer — it is wrong whichever way the real request goes
  • Call NDS.CooldownButton.reset() when the request fails, so the user can retry at once rather than serving out a cooldown for a call that never reached the server
  • Do not use this component as a generic submit guard for forms. Use a regular disabled state tied to the form's submission lifecycle instead
  • Do not use it for long cooldowns (over a few minutes). The countdown reads as nagging and ties the user to the page. Show a timestamp and refresh-on-load instead
  • Set data-resend-label when the first action and the repeat action read differently. "Send code" on first use and "Resend" on every cycle after is clearer than leaving "Resend" on a button that has never been clicked
  • Keep countdown templates short. "Resend in 30s" fits; a full sentence does not. The label redraws every second
  • Confirm the send from the response, not from the click. Listen for nds:cooldown:triggered, issue the request there, and call NDS.Alert.create from its success path — with an error variant on the failure path. A confirmation tied to the click reports success even when the request failed
  • Write a concrete confirmation message ("A new code has been sent to your mobile number.") rather than a generic "Success". Users need to know what succeeded
  • Cooldowns under 5 seconds feel abrupt. Cooldowns over 60 seconds should trigger a dedicated "please wait" screen, not a button label

Data Attributes

AttributeDescription
data-cooldownSeconds to hold the cooldown. Required to opt in. Non-positive values skip the cooldown entirely. Read once at wire time; editing after page load has no effect
data-cooldown-labelCountdown text template. {s} is replaced by the seconds remaining, every time it appears, so a bilingual label can name it once per language. Default {s} (number only). This is not printf: a label using %s, %d, {seconds} or a typo never counts down, and logs an NDS CooldownButton console warning when the button is wired
data-resend-labelLabel to restore after the first completed cycle. Omit to keep the initial label across cycles. A mid-loading reset() always restores the initial label

Events

All events bubble and fire on the button element. Listen for them to wire the request, its confirmation, analytics, and any parallel UI updates around the countdown.

EventFires
nds:cooldown:triggeredLoading ends and the cooldown starts. Issue your request here, and confirm from its response
nds:cooldown:tickEvery second during the cooldown. event.detail.remaining is the seconds left, including a first tick at the full duration
nds:cooldown:endCooldown completed naturally or reset() was called. Button is re-enabled and the label is restored

JavaScript API

The NDS.CooldownButton API provides programmatic control for dynamically added buttons and for aborting a cooldown in flight. Auto-initialization handles everything for static markup; no JS call is needed for the common case.

// ── Auto-initialization ────────────────────────────── // Every .nds-cooldown on the page is wired on page load. // Elements added to the DOM later are wired automatically. // Call init() manually only if you disabled the loader. NDS.CooldownButton.init(); // ── Trigger the cycle programmatically ─────────────── // Useful when the cooldown should start from a flow other // than the button's own click (e.g. after a form submit). const btn = document.querySelector('#my-resend-btn'); NDS.CooldownButton.start(btn); // The send already happened elsewhere — throttle the button // WITHOUT re-running the request handler on // nds:cooldown:triggered. tick and end still fire. NDS.CooldownButton.start(btn, { silent: true }); // Resume a cooldown across a page load: the user has 20 of // the 30 seconds left. seconds is its own opt-in, so a button // with no data-cooldown can be driven entirely from JS. NDS.CooldownButton.start(btn, { seconds: 20, silent: true }); // ── Abort an in-flight cooldown ────────────────────── // Re-enables the button, clears the tick timer, and restores the // post-send (data-resend-label) label if set, otherwise the original. NDS.CooldownButton.reset(btn); // ── Listen for lifecycle events ────────────────────── btn.addEventListener('nds:cooldown:triggered', () => { // The countdown just started. Issue the request here — see // "Bind the cooldown to a real request" below for the full shape. }); btn.addEventListener('nds:cooldown:tick', (e) => { console.log('seconds remaining:', e.detail.remaining); }); btn.addEventListener('nds:cooldown:end', () => { // Button is re-enabled and restored. }); // ── Bind the cooldown to a real request ────────────── // The component owns the throttle and the label. The request, its // loading state and its confirmation are yours — all from one event. // // The countdown starts on the click, because that is when the endpoint // was hit. There is no built-in loading phase: a fixed timer cannot know // how long a response takes. Stamp the button's own loading state and // clear it when the response lands, and the state is real. // // [data-state~="loading"] hides the label, so the countdown ticks under // the spinner and is already at the right number when the state comes off. btn.addEventListener('nds:cooldown:triggered', async () => { NDS.State.add(btn, 'loading'); try { // NDS.request throws on a non-OK status, so a 500 reaches the catch // below. Plain fetch resolves on one, reporting a failed resend as // though it had worked. await NDS.request('/api/resend', { method: 'POST' }); NDS.Alert.create({ variant: 'success', title: 'Code sent', display: 'toast', position: 'top', duration: 4000 }); } catch (err) { NDS.Alert.create({ variant: 'error', title: 'Could not send the code', display: 'toast', position: 'top', duration: 0 }); // Let the user retry immediately instead of serving out a cooldown // for a request that never reached the server. NDS.CooldownButton.reset(btn); } finally { // Always clear it — the component never touches 'loading'. NDS.State.remove(btn, 'loading'); } });
Last Modified Date: 02/09/2026 - 12:00 AM
Was this page useful?
60% of users said Yes from 2843 Feedbacks