Countdown
A countdown is a component that displays the remaining time until a specific event or deadline.
0
01234567890
01234567890
01234567890
01234567890
01234567890
0123456789"use client";
import { useEffect, useState } from "react";
import { Countdown } from "@/components/core/countdown";
export default function CountdownExample() {
const targetDate = new Date();
targetDate.setDate(targetDate.getDate() + 1);
const calculateTimeLeft = () => {
const difference = +targetDate - +new Date();
if (difference <= 0) return { hours: 0, minutes: 0, seconds: 0 };
return {
hours: Math.floor((difference / (1000 * 60 * 60)) % 24),
minutes: Math.floor((difference / 1000 / 60) % 60),
seconds: Math.floor((difference / 1000) % 60)
};
};
const [timeLeft, setTimeLeft] = useState(calculateTimeLeft());
useEffect(() => {
const timer = setInterval(() => {
const newTimeLeft = calculateTimeLeft();
setTimeLeft(newTimeLeft);
if (newTimeLeft.hours === 0 && newTimeLeft.minutes === 0 && newTimeLeft.seconds === 0) {
clearInterval(timer);
}
}, 1000);
return () => clearInterval(timer);
}, []);
return (
<div className="flex items-center gap-0.5 text-xl font-semibold">
<Countdown value={timeLeft.hours} padStart={true} />
<span className="text-zinc-500">:</span>
<Countdown value={timeLeft.minutes} padStart={true} />
<span className="text-zinc-500">:</span>
<Countdown value={timeLeft.seconds} padStart={true} />
</div>
);
}
Installation
Install the following dependencies:
npm install motion
Copy and paste the following code into your project:
"use client";
import { useEffect, useId } from "react";
import { MotionValue, motion, useSpring, useTransform, motionValue } from "motion/react";
import useMeasure from "react-use-measure";
const TRANSITION = {
type: "spring" as const,
stiffness: 280,
damping: 18,
mass: 0.3
};
function Digit({ value, place }: { value: number; place: number }) {
const valueRoundedToPlace = Math.floor(value / place) % 10;
const initial = motionValue(valueRoundedToPlace);
const animatedValue = useSpring(initial, TRANSITION);
useEffect(() => {
animatedValue.set(valueRoundedToPlace);
}, [animatedValue, valueRoundedToPlace]);
return (
<div className="relative inline-block w-[1ch] overflow-x-visible overflow-y-clip leading-none tabular-nums">
<div className="invisible">0</div>
{Array.from({ length: 10 }, (_, i) => (
<Number key={i} mv={animatedValue} number={i} />
))}
</div>
);
}
function Number({ mv, number }: { mv: MotionValue<number>; number: number }) {
const uniqueId = useId();
const [ref, bounds] = useMeasure();
const y = useTransform(mv, (latest) => {
if (!bounds.height) return 0;
const placeValue = latest % 10;
const offset = (10 + number - placeValue) % 10;
let memo = offset * bounds.height;
if (offset > 5) {
memo -= 10 * bounds.height;
}
return memo;
});
if (!bounds.height) {
return (
<span ref={ref} className="invisible absolute">
{number}
</span>
);
}
return (
<motion.span
style={{ y }}
layoutId={`${uniqueId}-${number}`}
className="absolute inset-0 flex items-center justify-center"
transition={TRANSITION}
ref={ref}>
{number}
</motion.span>
);
}
type SlidingNumberProps = {
value: number;
padStart?: boolean;
decimalSeparator?: string;
};
export function Countdown({ value, padStart = false, decimalSeparator = "." }: SlidingNumberProps) {
const absValue = Math.abs(value);
const [integerPart, decimalPart] = absValue.toString().split(".");
const integerValue = parseInt(integerPart, 10);
const paddedInteger = padStart && integerValue < 10 ? `0${integerPart}` : integerPart;
const integerDigits = paddedInteger.split("");
const integerPlaces = integerDigits.map((_, i) => Math.pow(10, integerDigits.length - i - 1));
return (
<div className="flex items-center">
{value < 0 && "-"}
{integerDigits.map((_, index) => (
<Digit
key={`pos-${integerPlaces[index]}`}
value={integerValue}
place={integerPlaces[index]}
/>
))}
{decimalPart && (
<>
<span>{decimalSeparator}</span>
{decimalPart.split("").map((_, index) => (
<Digit
key={`decimal-${index}`}
value={parseInt(decimalPart, 10)}
place={Math.pow(10, decimalPart.length - index - 1)}
/>
))}
</>
)}
</div>
);
}
Update the import paths to match your project setup.
Usage
"use client";
import { useEffect, useState } from "react";
import { Countdown } from "@/components/core/countdown";
export default function CountdownExample() {
const targetDate = new Date();
targetDate.setDate(targetDate.getDate() + 1);
const calculateTimeLeft = () => {
const difference = +targetDate - +new Date();
if (difference <= 0) return { hours: 0, minutes: 0, seconds: 0 };
return {
hours: Math.floor((difference / (1000 * 60 * 60)) % 24),
minutes: Math.floor((difference / 1000 / 60) % 60),
seconds: Math.floor((difference / 1000) % 60)
};
};
const [timeLeft, setTimeLeft] = useState(calculateTimeLeft());
useEffect(() => {
const timer = setInterval(() => {
const newTimeLeft = calculateTimeLeft();
setTimeLeft(newTimeLeft);
if (newTimeLeft.hours === 0 && newTimeLeft.minutes === 0 && newTimeLeft.seconds === 0) {
clearInterval(timer);
}
}, 1000);
return () => clearInterval(timer);
}, []);
return (
<div className="flex items-center gap-0.5 text-xl font-semibold">
<Countdown value={timeLeft.hours} padStart={true} />
<span className="text-zinc-500">:</span>
<Countdown value={timeLeft.minutes} padStart={true} />
<span className="text-zinc-500">:</span>
<Countdown value={timeLeft.seconds} padStart={true} />
</div>
);
}
Props
Prop | Type | Default |
---|---|---|
decimalSeparator? | string | "." |
padStart? | boolean | false |
value? | number | - |