Cooldown Ring Button — Free Rate-Limit JS Button Snippet
Cooldown Ring Button · Buttons · Plain HTML, CSS & JS · Live preview
Copy & paste
Source Code
<div class="cr-wrap">
<button type="button" class="cr-btn" id="crBtn">
<svg class="cr-ring" width="22" height="22" viewBox="0 0 22 22">
<circle class="cr-ring-track" cx="11" cy="11" r="9" />
<circle class="cr-ring-fill" id="crRingFill" cx="11" cy="11" r="9" />
</svg>
<span id="crLabel">Send message</span>
</button>
<p class="cr-status" id="crStatus">Ready to send</p>
</div>* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, -apple-system, sans-serif; background: #0f172a; color: #e2e8f0; display: flex; justify-content: center; align-items: center; min-height: 100vh; padding: 24px; }
.cr-wrap { text-align: center; width: 100%; max-width: 240px; }
.cr-btn {
display: inline-flex; align-items: center; gap: 9px;
border: none; border-radius: 11px; background: #6366f1; color: #fff;
font-family: inherit; font-size: 14px; font-weight: 700;
padding: 12px 20px; cursor: pointer; transition: background 0.15s, transform 0.1s;
}
.cr-btn:hover:not(:disabled) { background: #4f46e5; }
.cr-btn:active:not(:disabled) { transform: scale(0.97); }
.cr-btn:disabled { background: #334155; cursor: not-allowed; }
.cr-ring { flex-shrink: 0; transform: rotate(-90deg); }
.cr-ring-track { fill: none; stroke: rgba(255,255,255,0.18); stroke-width: 2.5; }
.cr-ring-fill { fill: none; stroke: #fff; stroke-width: 2.5; stroke-linecap: round; stroke-dasharray: 56.5; stroke-dashoffset: 56.5; transition: stroke-dashoffset 0.1s linear; }
.cr-btn:disabled .cr-ring-track { stroke: rgba(255,255,255,0.1); }
.cr-btn:disabled .cr-ring-fill { stroke: #94a3b8; }
.cr-status { margin-top: 12px; font-size: 12px; color: #94a3b8; font-weight: 600; min-height: 16px; }var btn = document.getElementById('crBtn');
var label = document.getElementById('crLabel');
var ringFill = document.getElementById('crRingFill');
var status = document.getElementById('crStatus');
var COOLDOWN_MS = 6000;
var CIRCUMFERENCE = 56.5;
var timerId = null;
var cooling = false;
function startCooldown() {
cooling = true;
btn.disabled = true;
var startTime = Date.now();
// Fire the real action once, then hold the button disabled while a ring
// sweeps around it to show exactly how much cooldown time remains.
function tick() {
var elapsed = Date.now() - startTime;
var remaining = Math.max(0, COOLDOWN_MS - elapsed);
var progress = 1 - remaining / COOLDOWN_MS;
ringFill.style.strokeDashoffset = String(CIRCUMFERENCE * (1 - progress));
var secondsLeft = Math.ceil(remaining / 1000);
if (remaining <= 0) {
endCooldown();
return;
}
label.textContent = 'Wait ' + secondsLeft + 's';
status.textContent = 'Sent \u2014 next send unlocks in ' + secondsLeft + 's';
timerId = requestAnimationFrame(tick);
}
tick();
}
function endCooldown() {
cooling = false;
cancelAnimationFrame(timerId);
btn.disabled = false;
label.textContent = 'Send message';
status.textContent = 'Ready to send';
ringFill.style.strokeDashoffset = String(CIRCUMFERENCE);
}
btn.addEventListener('click', function () {
if (cooling) return;
// Real send action goes here.
status.textContent = 'Message sent!';
startCooldown();
});Extend with AI
Take This Snippet Further With AI
The AI prompt for this one is for Patreon supporters ($3 a month, 7 days free), along with a site with no ads. Supporters enter the passcode once and it is remembered in this browser.
What's included
Features
About this UI Snippet
Cooldown Ring Button — Rate-Limited Action Button with an SVG Countdown Ring

A cooldown ring button prevents spam-clicking an action — sending a message, requesting an OTP, submitting a form — by disabling itself immediately after use and visually counting down, via a filling ring around the button itself, until it can be pressed again. It replaces a plain disabled state (which gives no sense of *when* the button will come back) with a concrete, glanceable timer baked directly into the control. This snippet builds it in plain HTML, CSS, and vanilla JavaScript with no dependency.
An SVG ring driven by stroke-dashoffset
The ring is two overlapping SVG <circle> elements: a static .cr-ring-track and an animated .cr-ring-fill. The fill circle's stroke-dasharray is set to its own circumference (56.5, precomputed for r="9"), which turns the dash pattern into "one dash exactly as long as the whole circle, then a gap." Setting stroke-dashoffset to that same value hides the entire stroke; animating stroke-dashoffset down toward 0 reveals the circle progressively, clockwise from the top (the rotate(-90deg) on the parent <svg> moves the start point from 3 o'clock to 12 o'clock).
requestAnimationFrame against Date.now(), not setInterval
startCooldown() records a startTime and drives the ring with a tick() function scheduled via requestAnimationFrame, computing elapsed and remaining from Date.now() on every frame rather than counting down a fixed interval. This keeps the ring and the "Wait Ns" label numerically accurate even if the tab is briefly backgrounded or a frame is dropped, which a naive setInterval(fn, 1000) countdown would drift on.
Disabling the control, not just styling it
While cooling, btn.disabled = true is set on the real <button> element — not just a visual class — so the click handler's early-return guard is a second line of defense, not the only one. A disabled native button also can't receive focus via Tab in most browsers and is automatically skipped by assistive technology as non-interactive, which a CSS-only "looks disabled" button would not guarantee.
Wiring the real action
The actual send/submit call belongs right where the click handler fires it, before startCooldown() is called — the pattern only cares that a single click triggers the action exactly once and then locks the button until COOLDOWN_MS elapses. Change COOLDOWN_MS to match how expensive or how rate-limited the underlying action actually is: a few seconds for a chat send button, a full 60 seconds for an OTP resend.
Step by step
How to Use
- 1Paste HTML, CSS, and JSA "Send message" button renders with a thin ring track around a small icon and a status line beneath it.
- 2Click to trigger the actionThe click handler fires the action once, then immediately disables the button and starts the cooldown ring.
- 3Watch the ring and labelThe ring sweeps clockwise while the label counts down the remaining whole seconds until the button re-enables.
- 4Wait for it to re-enableOnce COOLDOWN_MS has fully elapsed, the button re-enables, the ring resets, and the label returns to its resting text.
- 5Tune the cooldown lengthChange the COOLDOWN_MS constant in the JS panel to match how rate-limited the real action actually is.
- 6Wire your real actionPut the actual API call or state update at the top of the click handler, right before startCooldown() runs.
Real-world uses
Common Use Cases
Got questions?
Frequently Asked Questions
An SVG circle with its dash array set to its own circumference gives precise, easily-animated control over exactly how much of the stroke is visible via stroke-dashoffset, and it composes cleanly with a rotate() transform to control the start angle — a conic-gradient approach requires more CSS trickery to mask into a ring shape and round the stroke ends.
Computing elapsed time from Date.now() on every animation frame keeps the ring and label numerically accurate regardless of dropped frames or a briefly backgrounded tab. A fixed setInterval(fn, 1000) countdown can drift because it assumes each tick fires exactly on schedule, which browsers do not guarantee.
Two things: the native button.disabled attribute is set to true, which browsers already refuse to fire click events on, and the click handler itself also checks the cooling flag and returns early as a second guard.
Change the single COOLDOWN_MS constant in the JS panel — everything else (the ring animation, the countdown label, and when the button re-enables) derives from that one value.
Put it at the top of the click handler, before startCooldown() is called, so the real action fires exactly once per successful click and the cooldown always starts immediately afterward.
Yes. Keep cooling and the remaining-time value in component state, store the requestAnimationFrame id in a ref so updating it does not trigger re-renders, and bind the ring circle's stroke-dashoffset to the computed progress. Clean up the animation frame on unmount.