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
<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>
// The button owns the throttle and the countdown. The request, its
// loading state and its confirmation are yours, all from this event.
// [data-state~="loading"] hides the label, so the countdown ticks under
// the spinner and is already correct when the state comes off.
var btn = document.getElementById('cooldown-demo-resend');
btn.addEventListener('nds:cooldown:triggered', function() {
NDS.State.add(btn, 'loading');
sendCode()
.then(function() {
NDS.Alert.create({
variant: 'success',
title: 'Verification code sent',
description: 'A new code has been sent to your mobile number.',
display: 'toast', position: 'top', duration: 4000
});
})
.finally(function() {
NDS.State.remove(btn, 'loading');
});
});
Simple Cooldown
For rate-limited retry buttons where you just need to prevent rapid repeats without a confirmation step
<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
Activates on every .nds-cooldown on the page and on any element added later. No wiring code required.
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.
Swaps the button label to your template every second, with every {s} replaced by the seconds remaining, until the cooldown ends.
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.
After the first completed cycle the button can show a different label (for example "Send code" becomes "Resend").
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 fromnds:cooldown:triggeredand 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-labelwhen 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
| Attribute | Description |
|---|---|
data-cooldown | Seconds 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-label | Countdown 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-label | Label 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.
| Event | Fires |
|---|---|
nds:cooldown:triggered | Loading ends and the cooldown starts. Issue your request here, and confirm from its response |
nds:cooldown:tick | Every second during the cooldown. event.detail.remaining is the seconds left, including a first tick at the full duration |
nds:cooldown:end | Cooldown 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.