John Doe
john@example.com
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.
Install the required dependencies:
npm install framer-motion lucide-react"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
* 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.