Skip to content

CooldownTimer

SM · 950B gzip budget

Countdown until an action becomes available again.

Category
Feedback & status
Budget
sm, single-purpose control
External runtime
React / React DOM only
axe fixtureSSR rendersource scanbundle budget

Preview

Live fixture
Preview locale

Try the component here. Locale-aware formatting and direction follow the selected language; example content and labels are not automatically translated. Overlay demos use interactive launchers.

Install

npx shadcn@latest add https://gear5-ui.vercel.app/r/cooldown-timer.json

Copies the source into your project.

New here? Set up Tailwind and import aliases first →

Source & props

Edit on GitHub ↗

Prop types and inline documentation are included below. This is repository source; the installer rewrites shared imports for your project.

"use client";

import React, { useCallback, useEffect, useRef, useState } from "react";

export interface CooldownTimerProps {
  durationMs: number;
  onComplete?: () => void;
  label?: string;
}

function formatMs(ms: number): string {
  const totalSeconds = Math.ceil(ms / 1000);
  const minutes = Math.floor(totalSeconds / 60);
  const seconds = totalSeconds % 60;
  if (minutes > 0) return `${minutes}:${String(seconds).padStart(2, "0")}`;
  return `${seconds}s`;
}

export function CooldownTimer({
  durationMs,
  onComplete,
  label = "Cooldown",
}: CooldownTimerProps) {
  const [remaining, setRemaining] = useState(durationMs);
  const startTime = useRef(0);
  const rafId = useRef<number>(0);

  useEffect(() => {
    startTime.current = Date.now();

    const tick = () => {
      const elapsed = Date.now() - startTime.current;
      const left = Math.max(0, durationMs - elapsed);
      setRemaining(left);
      if (left > 0) {
        rafId.current = requestAnimationFrame(tick);
      } else {
        onComplete?.();
      }
    };

    rafId.current = requestAnimationFrame(tick);
    return () => {
      if (rafId.current) cancelAnimationFrame(rafId.current);
    };
  }, [durationMs, onComplete]);

  const progress = remaining > 0 ? remaining / durationMs : 0;

  return (
    <div
      role="timer"
      aria-live="polite"
      aria-label={`${label}: ${formatMs(remaining)} remaining`}
      style={{
        display: "inline-flex",
        flexDirection: "column",
        alignItems: "center",
        gap: "0.5rem",
      }}
    >
      <div
        aria-hidden="true"
        style={{
          width: 48,
          height: 48,
          borderRadius: "50%",
          background: `conic-gradient(#1a73e8 ${progress * 360}deg, #e0e0e0 ${progress * 360}deg)`,
          display: "flex",
          alignItems: "center",
          justifyContent: "center",
        }}
      >
        <div
          style={{
            width: 40,
            height: 40,
            borderRadius: "50%",
            backgroundColor: "#fff",
            display: "flex",
            alignItems: "center",
            justifyContent: "center",
            fontSize: "0.75rem",
            fontWeight: 700,
            color: "#333",
            fontVariantNumeric: "tabular-nums",
          }}
        >
          {formatMs(remaining)}
        </div>
      </div>
    </div>
  );
}

What CI checks

Quality contract
  • Bundled, minified and gzipped against its tier budget
  • Audited by axe in the state previewed above
  • Rendered through react-dom/server with no browser globals
  • Scanned for network calls, dangerous sinks, and unguarded animation