Timer

An interactive countdown timer built with React and Framer Motion featuring a smoothly animated square progress border that decreases as time runs. It includes urgency-based color transitions and a formatted HH:MM:SS time display with a modern, responsive UI.

framer-motionlucide-react
00:00:00

Installation

Install the required dependencies:

npm install framer-motion lucide-react
components/ui/timer.tsx
"use client";
import { Pause, Play } from "lucide-react";
import React, { useState, useEffect, useRef } from "react";
import { motion } from "framer-motion";

function Timer() {
  const [timerStart, setTimerStart] = useState(false);
  const [timerHaveValue, setTimerHaveValue] = useState(false);
  const [timerValue, setTimerValue] = useState({ hr: 0, min: 0, sec: 0 });
  const [initialTime, setInitialTime] = useState(0);

  const intervalRef = useRef<NodeJS.Timeout | null>(null);

  const remainingSeconds =
    timerValue.hr * 3600 + timerValue.min * 60 + timerValue.sec;

  const handleChange = (e: any) => {
    const { name, value } = e.target;
    let num = Number(value);

    if (num < 0) num = 0;
    if ((name === "min" || name === "sec") && num > 59) num = 59;

    setTimerValue((prev) => ({
      ...prev,
      [name]: num,
    }));
  };

  const getTotalSeconds = () => {
    return timerValue.hr * 3600 + timerValue.min * 60 + timerValue.sec;
  };

  useEffect(() => {
    if (timerStart) {
      intervalRef.current = setInterval(() => {
        setTimerValue((prev) => {
          let total = prev.hr * 3600 + prev.min * 60 + prev.sec;

          if (total <= 1) {
            if (intervalRef.current) {
              clearInterval(intervalRef.current);
            }
            setTimerStart(false);
            setTimerHaveValue(false);
            return { hr: 0, min: 0, sec: 0 };
          }

          total -= 1;

          const hr = Math.floor(total / 3600);
          const min = Math.floor((total % 3600) / 60);
          const sec = total % 60;

          return { hr, min, sec };
        });
      }, 1000);
    }

    return () => {
      if (intervalRef.current) {
        clearInterval(intervalRef.current);
      }
    };
  }, [timerStart]);

  const handleEnd = () => {
    if (intervalRef.current) {
      clearInterval(intervalRef.current);
    }
    setTimerStart(false);
    setTimerHaveValue(false);
    setTimerValue({ hr: 0, min: 0, sec: 0 });
  };

  const totalSeconds =
    timerValue.hr * 3600 + timerValue.min * 60 + timerValue.sec;

  let textColor = timerStart ? "text-[#B6F500]" : "text-[#ECFAE5]";

  if ((timerStart || timerHaveValue) && totalSeconds <= 3 && totalSeconds > 0) {
    textColor = "text-[#FF3838]";
  } else if ((timerStart || timerHaveValue) && totalSeconds < 10) {
    textColor = "text-[#FFC300]";
  }
  return (
    <div className="w-[20rem] h-[26rem] bg-black shadow-2xl text-white flex flex-col items-center py-2 px-2 rounded-[5rem] overflow-hidden">
      <SquareProgress
        currentTime={timerHaveValue ? remainingSeconds : 0}
        initialTime={timerHaveValue ? initialTime : 0}
      >
        {timerHaveValue ? (
          timerStart ? (
            <Pause
              size={60}
              onClick={() => setTimerStart(false)}
              className="cursor-pointer"
              color={
                initialTime > 0 && remainingSeconds <= 3
                  ? "#FF3838"
                  : initialTime > 0 && remainingSeconds < 10
                    ? "#FFC300"
                    : "#B6F500"
              }
            />
          ) : (
            <Play
              size={60}
              onClick={() => setTimerStart(true)}
              className="cursor-pointer"
              color={
                initialTime > 0 && remainingSeconds <= 3
                  ? "#FF3838"
                  : initialTime > 0 && remainingSeconds < 10
                    ? "#FFC300"
                    : "#B6F500"
              }
            />
          )
        ) : (
          <div className="flex flex-col items-center gap-2 w-full">
            <div className="flex justify-between gap-3">
              <div className="flex flex-col items-center">
                <label className="text-xs font-semibold text-lime-400 mb-1">
                  HR
                </label>
                <input
                  name="hr"
                  type="number"
                  min={0}
                  max={100}
                  value={timerValue.hr}
                  onChange={handleChange}
                  className="w-15 h-12 text-center text-lg font-bold 
                 bg-zinc-900 text-white 
                 border-2 border-lime-500/40 
                 rounded-2xl 
                 outline-none
                 focus:border-lime-400 focus:ring-2 focus:ring-lime-500/30
                 transition-all duration-200
                 appearance-none"
                />
              </div>

              <div className="flex flex-col items-center">
                <label className="text-xs font-semibold text-lime-400 mb-1">
                  MIN
                </label>
                <input
                  name="min"
                  type="number"
                  min={0}
                  max={59}
                  value={timerValue.min}
                  onChange={handleChange}
                  className="w-15 h-12 text-center text-lg font-bold 
                 bg-zinc-900 text-white 
                 border-2 border-lime-500/40 
                 rounded-2xl 
                 outline-none
                 focus:border-lime-400 focus:ring-2 focus:ring-lime-500/30
                 transition-all duration-200
                 appearance-none"
                />
              </div>

              <div className="flex flex-col items-center">
                <label className="text-xs font-semibold text-lime-400 mb-1">
                  SEC
                </label>
                <input
                  name="sec"
                  type="number"
                  min={0}
                  max={59}
                  value={timerValue.sec}
                  onChange={handleChange}
                  className="w-15 h-12 text-center text-lg font-bold 
                 bg-zinc-900 text-white 
                 border-2 border-lime-500/40 
                 rounded-2xl 
                 outline-none
                 focus:border-lime-400 focus:ring-2 focus:ring-lime-500/30
                 transition-all duration-200
                 appearance-none"
                />
              </div>
            </div>

            <motion.button
              whileTap={{ scale: 0.9 }}
              onClick={() => {
                const total = getTotalSeconds();
                if (getTotalSeconds() > 0) {
                  setInitialTime(total);
                  console.log(total);

                  setTimerHaveValue(true);
                }
              }}
              className="bg-[#ECFAE5] cursor-pointer w-[50%] text-black rounded-full font-bold px-3 py-1 shadow-lg"
            >
              Set
            </motion.button>
          </div>
        )}
      </SquareProgress>

      <div className="w-full h-1/2 flex flex-col items-center gap-3 mt-5">
        <div className={`text-4xl font-bold py-2 ${textColor}`}>
          {String(timerValue.hr).padStart(2, "0")}:
          {String(timerValue.min).padStart(2, "0")}:
          {String(timerValue.sec).padStart(2, "0")}
        </div>

        <div className="w-full flex flex-col gap-5 justify-center">
          <motion.button
            whileTap={{ scale: 0.9 }}
            whileHover={{ scale: 1.1 }}
            onClick={handleEnd}
            className="flex items-center cursor-pointer font-bold justify-center gap-2 bg-[#ECFAE5] w-[80%] mx-auto text-black rounded-full px-5 py-2 shadow-lg"
          >
            End
          </motion.button>
        </div>
        <div className="flex w-full justify-between mt-3 px-10 ">
          <motion.button
            whileTap={{ scale: 0.9 }}
            onClick={() => {
              if (!timerHaveValue) {
                setTimerValue({ hr: 0, min: 5, sec: 0 });
              }
            }}
            className="bg-[#ECFAE5] cursor-pointer text-black font-bold py-1 px-2 rounded-xl"
          >
            5 min
          </motion.button>
          <motion.button
            whileTap={{ scale: 0.9 }}
            onClick={() => {
              if (!timerHaveValue) {
                setTimerValue({ hr: 0, min: 10, sec: 0 });
              }
            }}
            className="bg-[#ECFAE5] cursor-pointer text-black font-bold py-1 px-2 rounded-xl"
          >
            10 min
          </motion.button>
          <motion.button
            whileTap={{ scale: 0.9 }}
            onClick={() => {
              if (!timerHaveValue) {
                setTimerValue({ hr: 0, min: 15, sec: 0 });
              }
            }}
            className="bg-[#ECFAE5] cursor-pointer text-black font-bold py-1 px-2 rounded-xl"
          >
            15 min
          </motion.button>
        </div>
      </div>
    </div>
  );
}

export default Timer;

type Props = {
  currentTime: number;
  initialTime: number;
  children?: React.ReactNode;
};

function SquareProgress({ currentTime, initialTime, children }: Props) {
  const width = 266;
  const height = 166;
  const strokeWidth = 14;
  const radius = 55;

  // Calculate rectangle perimeter properly
  const perimeter = 2 * (width + height - 4 * radius) + 2 * Math.PI * radius;

  const progress = initialTime > 0 ? Math.max(currentTime / initialTime, 0) : 0;

  return (
    <div
      style={{ width: 280, height: 200 }}
      className="relative flex items-center justify-center"
    >
      <svg width={280} height={190} className="absolute top-3">
        <rect
          x={7}
          y={7}
          width={width}
          height={height}
          rx={radius}
          ry={radius}
          fill="transparent"
          stroke={
            initialTime > 0 && currentTime <= 3
              ? "#740A03"
              : initialTime > 0 && currentTime < 10
                ? "#C75D2C"
                : "#3D5300"
          }
          strokeWidth={strokeWidth}
        />

        <motion.rect
          x={7}
          y={7}
          width={width}
          height={height}
          rx={radius}
          ry={radius}
          fill="transparent"
          stroke={
            initialTime > 0 && currentTime <= 3
              ? "#FF3838"
              : initialTime > 0 && currentTime < 10
                ? "#FFC300"
                : "#B6F500"
          }
          strokeWidth={strokeWidth}
          strokeDasharray={perimeter}
          strokeDashoffset={perimeter * (1 - progress)}
          strokeLinecap="round"
          initial={false}
          animate={{
            strokeDashoffset: perimeter * (1 - progress),
          }}
          transition={{ ease: "linear", duration: 0.2 }}
          style={{
            transformOrigin: "50% 50%",
            transform: "",
          }}
        />
      </svg>

      <div className="relative top-2 z-10 rounded-2xl w-[80%] h-[60%] flex items-center justify-center">
        {children}
      </div>
    </div>
  );
}

Usecase

00:00:00

* A React countdown timer with an animated square progress border and dynamic color changes based on remaining time. Displays time in a clean HH:MM:SS format.