Docs

Shifting Countdown

A Shifting Countdown Component dynamically updates and transitions between countdown values with smooth animations for an engaging user experience.

Installation

Install dependencies

npm install framer-motion lucide-react

Run the following command

It will create a new file shifting-countdown.tsx inside the components/mage-ui/widget directory.

mkdir -p components/mage-ui/widget && touch components/mage-ui/widget/shifting-countdown.tsx

Paste the code

Open the newly created file and paste the following code:

import { useAnimate } from "framer-motion";
import { useEffect, useRef, useState } from "react";
 
// NOTE: Change this date to whatever date you want to countdown to :)
const COUNTDOWN_FROM = "2024-10-01";
const SECOND = 1000;
const MINUTE = SECOND * 60;
const HOUR = MINUTE * 60;
const DAY = HOUR * 24;
 
type CountdownUnit = "Day" | "Hour" | "Minute" | "Second";
 
interface CountdownItemProps {
  unit: CountdownUnit;
  text: string;
}
 
const ShiftingCountdown = (): JSX.Element => {
  return (
    <div className="bg-gradient-to-br from-violet-600 to-indigo-600 p-4">
      <div className="mx-auto flex w-full max-w-6xl items-center bg-white">
        <CountdownItem unit="Day" text="days" />
        <CountdownItem unit="Hour" text="hours" />
        <CountdownItem unit="Minute" text="minutes" />
        <CountdownItem unit="Second" text="seconds" />
      </div>
    </div>
  );
};
 
const CountdownItem = ({ unit, text }: CountdownItemProps): JSX.Element => {
  const { ref, time } = useTimer(unit);
  return (
    <div className="flex h-24 w-[100vw] flex-col items-center justify-center gap-1 border-r-[1px] border-slate-200 font-mono md:h-36 md:gap-2">
      <div className="relative w-full overflow-hidden text-center">
        <span
          ref={ref}
          className="block text-2xl font-medium text-black md:text-4xl lg:text-6xl xl:text-7xl"
        >
          {time}
        </span>
      </div>
      <span className="text-xs font-light text-slate-500 md:text-sm lg:text-base">
        {text}
      </span>
    </div>
  );
};
 
interface UseTimerResult {
  ref: React.RefObject<HTMLElement>;
  time: number;
}
 
// NOTE: Framer motion exit animations can be a bit buggy when repeating
// keys and tabbing between windows. Instead of using them, we've opted here
// to build our own custom hook for handling the entrance and exit animations
const useTimer = (unit: CountdownUnit): UseTimerResult => {
  const [ref, animate] = useAnimate();
  const intervalRef = useRef<NodeJS.Timeout | null>(null);
  const timeRef = useRef<number>(0);
  const [time, setTime] = useState<number>(0);
 
  useEffect(() => {
    intervalRef.current = setInterval(handleCountdown, 1000);
    return () => clearInterval(intervalRef.current || undefined);
  }, []);
 
  const handleCountdown = async (): Promise<void> => {
    const end = new Date(COUNTDOWN_FROM);
    const now = new Date();
    const distance = +end - +now;
    let newTime = 0;
 
    if (unit === "Day") {
      newTime = Math.floor(distance / DAY);
    } else if (unit === "Hour") {
      newTime = Math.floor((distance % DAY) / HOUR);
    } else if (unit === "Minute") {
      newTime = Math.floor((distance % HOUR) / MINUTE);
    } else {
      newTime = Math.floor((distance % MINUTE) / SECOND);
    }
 
    if (newTime !== timeRef.current) {
      // Exit animation
      await animate(
        ref.current,
        { y: ["0%", "-50%"], opacity: [1, 0] },
        { duration: 0.35 }
      );
      timeRef.current = newTime;
      setTime(newTime);
      // Enter animation
      await animate(
        ref.current,
        { y: ["50%", "0%"], opacity: [0, 1] },
        { duration: 0.35 }
      );
    }
  };
 
  return { ref, time };
};
 
export default ShiftingCountdown;