webdevpuneet.com

Cooldown Ring Button — Free Rate-Limit JS Button Snippet

Cooldown Ring Button · Buttons · Plain HTML, CSS & JS · Live preview

CategoryButtons

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>

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.

· Become a supporter

What's included

Features

SVG ring driven entirely by stroke-dasharray and stroke-dashoffset — no canvas
requestAnimationFrame against Date.now() keeps the countdown accurate even under dropped frames
Native button disabled attribute is set, not just a visual style, for real assistive-tech and keyboard behavior
Live "Wait Ns" label and status line update every frame from the same elapsed-time calculation
Single COOLDOWN_MS constant controls the entire cooldown duration
Click handler guards against re-entry even if the disabled attribute is bypassed
Ring resets cleanly to a hidden state the moment the cooldown ends
No external animation or countdown library required

About this UI Snippet

Cooldown Ring Button — Rate-Limited Action Button with an SVG Countdown Ring

Screenshot of the Cooldown Ring Button snippet rendered live

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

  1. 1
    Paste HTML, CSS, and JSA "Send message" button renders with a thin ring track around a small icon and a status line beneath it.
  2. 2
    Click to trigger the actionThe click handler fires the action once, then immediately disables the button and starts the cooldown ring.
  3. 3
    Watch the ring and labelThe ring sweeps clockwise while the label counts down the remaining whole seconds until the button re-enables.
  4. 4
    Wait 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.
  5. 5
    Tune the cooldown lengthChange the COOLDOWN_MS constant in the JS panel to match how rate-limited the real action actually is.
  6. 6
    Wire 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

OTP and verification code resend buttons
Prevent a user from spamming a resend endpoint by pairing this with an OTP input — the ring makes the wait period visible instead of just showing a greyed-out button.
Chat and comment send buttons
Throttle rapid-fire message sends in a chat widget or comment box without a jarring error toast on every extra click.
Any client-side rate-limited API call
Wrap any action that hits a rate-limited backend endpoint — a "regenerate" button, a webhook retry, a report-export trigger — so the UI itself enforces the same cadence as the server.
Countdown-ring visual pattern reference
The stroke-dashoffset ring technique is directly reusable for any circular progress indicator, such as a session-timeout warning or an upload progress badge.
Accessible disabled-state button
Because the native disabled attribute is toggled, screen readers and keyboard navigation correctly skip the button while it is cooling down.

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.