Docs

Team Clock

A customizable, animated clock displaying multiple time zones for remote teams.

Installation

Install dependencies

npm install framer-motion

Run the following command

It will create a new file team-clock.tsx inside the components/animata/widget directory.

mkdir -p components/animata/widget && touch components/animata/widget/team-clock.tsx

Paste the code

Open the newly created file and paste the following code:

"use client";
 
import { useEffect, useMemo, useRef, useState } from "react";
import { AnimatePresence, motion } from "framer-motion";
 
import { cn } from "@/lib/utils";
 
interface TeamClockProps {
  users: Array<{
    name: string;
    city: string;
    country: string;
    timeDifference: string;
    pfp: string;
  }>;
  clockSize: number;
  animationDuration: number;
  accentColor: string;
  backgroundColor: string;
  textColor: string;
  borderColor: string;
  hoverBackgroundColor: string;
  showSeconds: boolean;
  use24HourFormat: boolean;
}
 
export default function TeamClock({
  users,
  clockSize,
  animationDuration,
  accentColor = "#000",
  backgroundColor = "#ffffff",
  textColor = "#1f2937",
  borderColor = "#e5e7eb",
  hoverBackgroundColor = "#f3f4f6",
  showSeconds = false,
  use24HourFormat = false,
}: TeamClockProps) {
  const [isExpanded, setIsExpanded] = useState(false);
  const [angle, setAngle] = useState(0);
  const [currentTime, setCurrentTime] = useState(new Date());
  const [isMobile, setIsMobile] = useState(false);
  const [selectedUser, setSelectedUser] = useState<string | null>(null);
  const [hoveredUser, setHoveredUser] = useState<string | null>(null);
 
  useEffect(() => {
    const checkMobile = () => {
      setIsMobile(window.innerWidth < 768);
    };
 
    checkMobile();
    window.addEventListener("resize", checkMobile);
 
    return () => window.removeEventListener("resize", checkMobile);
  }, []);
 
  useEffect(() => {
    const timer = setInterval(() => {
      setCurrentTime(new Date());
    }, 1000);
 
    return () => clearInterval(timer);
  }, []);
 
  const handleToggle = () => {
    setIsExpanded(!isExpanded);
  };
 
  const handleUserSelect = (userName: string, timeDifference: string) => {
    if (selectedUser === userName) {
      setSelectedUser(null);
      setAngle(0);
    } else {
      setSelectedUser(userName);
      setAngle(parseInt(timeDifference) * 30);
    }
  };
 
  const handleUserHover = (userName: string | null, timeDifference: string | null) => {
    if (userName && timeDifference) {
      setHoveredUser(userName);
      setAngle(parseInt(timeDifference) * 30);
    } else {
      setHoveredUser(null);
      if (!selectedUser) {
        setAngle(0);
      } else {
        const selectedUserData = users.find((user) => user.name === selectedUser);
        if (selectedUserData) {
          setAngle(parseInt(selectedUserData.timeDifference) * 30);
        }
      }
    }
  };
 
  return (
    <>
      <motion.div
        className={cn(
          "relative flex flex-col overflow-hidden rounded-lg border transition-shadow duration-300 hover:shadow-lg md:flex-row",
          "min-w-26 h-auto w-full md:w-[450px]",
          isMobile ? "team-clock-mobile" : "",
        )}
        style={{
          backgroundColor: backgroundColor,
          borderColor: borderColor,
          color: textColor,
        }}
        animate={{ width: isMobile ? "100%" : isExpanded ? "800px" : "400px" }}
        transition={{ duration: animationDuration }}
      >
        <div
          className={cn("flex flex-col rounded-lg p-4", {
            "w-full": isMobile || (!isExpanded && !isMobile),
            "w-1/2": isExpanded && !isMobile,
          })}
          style={{ backgroundColor: backgroundColor }}
        >
          <div
            className="mb-4 flex items-center justify-between border-b pb-4 pt-1"
            style={{ borderColor: borderColor }}
          >
            <h2 className="text-2xl font-bold">Team</h2>
            {!isMobile && (
              <ToggleButton
                onClick={handleToggle}
                accentColor={accentColor}
                textColor={textColor}
              />
            )}
          </div>
          <div className="flex flex-grow items-center justify-center">
            <Clock
              angle={angle}
              pressed={isExpanded}
              size={isMobile ? Math.min(clockSize, window.innerWidth - 40) : clockSize}
              animationDuration={animationDuration}
              accentColor={accentColor}
              textColor={textColor}
              backgroundColor={backgroundColor}
            />
          </div>
          <div className="my-4 text-center text-3xl font-semibold">
            {currentTime.toLocaleTimeString([], {
              hour: use24HourFormat ? "2-digit" : "numeric",
              minute: "2-digit",
              second: showSeconds ? "2-digit" : undefined,
              hour12: !use24HourFormat,
            })}
          </div>
        </div>
 
        {/* Add vertical dividing line */}
        {isExpanded && !isMobile && (
          <div className="h-full w-px" style={{ backgroundColor: borderColor }}></div>
        )}
 
        <AnimatePresence>
          {(isExpanded || isMobile) && (
            <motion.div
              className={cn("overflow-y-auto rounded-r-lg", {
                "team-clock-mobile-list w-full": isMobile,
                "w-1/2": !isMobile,
              })}
              style={{ backgroundColor: backgroundColor }}
              initial={isMobile ? { opacity: 1 } : { width: 0, opacity: 0 }}
              animate={isMobile ? { opacity: 1 } : { width: "50%", opacity: 1 }}
              exit={isMobile ? { opacity: 1 } : { width: 0, opacity: 0 }}
              transition={{ duration: animationDuration }}
            >
              <motion.div
                className="space-y-4 p-4"
                initial={isMobile ? { x: 0 } : { x: "100%" }}
                animate={{ x: 0 }}
                exit={isMobile ? { x: 0 } : { x: "100%" }}
                transition={{ duration: animationDuration }}
              >
                {users.map((user, index) => (
                  <ListElement
                    key={index}
                    name={user.name}
                    city={user.city}
                    country={user.country}
                    pfp={user.pfp}
                    timeDifference={user.timeDifference}
                    onSelect={handleUserSelect}
                    onHover={handleUserHover}
                    isSelected={selectedUser === user.name}
                    isHovered={hoveredUser === user.name}
                    currentTime={currentTime}
                    animationDuration={animationDuration}
                    accentColor={accentColor}
                    textColor={textColor}
                    hoverBackgroundColor={hoverBackgroundColor}
                  />
                ))}
              </motion.div>
            </motion.div>
          )}
        </AnimatePresence>
      </motion.div>
    </>
  );
}
 
interface ClockProps {
  angle: number;
  pressed: boolean;
  size: number;
  animationDuration: number;
  accentColor: string;
  textColor: string;
  backgroundColor: string;
}
 
function Clock({
  angle,
  size,
  animationDuration,
  accentColor,
  textColor,
  backgroundColor,
}: ClockProps) {
  const [time, setTime] = useState<Date | null>(null);
  const gradientRef = useRef<HTMLDivElement>(null);
 
  useEffect(() => {
    setTime(new Date());
    const interval = setInterval(() => {
      setTime(new Date());
    }, 1000);
 
    return () => clearInterval(interval);
  }, []);
 
  useEffect(() => {
    const isClockwise = angle > 0;
    if (gradientRef.current && time) {
      const hours = time.getHours();
      const minutes = time.getMinutes();
      const hourDegrees = (hours % 12) * 30 + minutes * 0.5;
      gradientRef.current.style.background = isClockwise
        ? `conic-gradient(from ${hourDegrees}deg, rgba(0,200,0,0.5), rgba(0,200,0,0) ${angle}deg)`
        : `conic-gradient(from ${
            hourDegrees + angle
          }deg, rgba(200,0,0,0.3), rgba(200,0,0,0.0) ${-angle}deg)`;
    }
  }, [angle, time]);
 
  if (!time) {
    return null;
  }
 
  const hours = time.getHours();
  const minutes = time.getMinutes();
  const seconds = time.getSeconds();
  const hourDegrees = (hours % 12) * 30 + minutes * 0.5;
  const minuteDegrees = minutes * 6;
  const secondDegrees = seconds * 6;
 
  return (
    <div
      className="flex items-center justify-center py-14"
      style={{ "--accent-color": accentColor } as React.CSSProperties}
    >
      <div
        className="relative flex items-center justify-center rounded-full border p-6 text-center text-xl"
        ref={gradientRef}
        style={{
          height: `${size}px`,
          width: `${size}px`,
          backgroundColor: backgroundColor,
          borderColor: textColor,
        }}
      >
        {Array.from({ length: 12 }, (_, i) => (
          <div
            key={i}
            className="absolute"
            style={{ transform: `rotate(${i * 30}deg) translate(0, -46%)` }}
          >
            <hr className="h-[20px] w-[3px] rounded-lg" style={{ backgroundColor: textColor }} />
          </div>
        ))}
 
        <motion.div
          className="absolute top-[50%] h-[100px] w-[5px] origin-bottom rounded-2xl"
          style={{ transformOrigin: "50% 0%", backgroundColor: textColor }}
          animate={{ rotate: 180 + hourDegrees + angle }}
          transition={{ duration: animationDuration }}
        />
        <motion.div
          className="absolute top-[50%] h-[120px] w-[4px] origin-bottom rounded-2xl"
          style={{
            transformOrigin: "50% 0%",
            backgroundColor: textColor,
            opacity: 0.6,
          }}
          animate={{ rotate: 180 + minuteDegrees }}
          transition={{ duration: animationDuration }}
        />
        <motion.div
          className="absolute top-[50%] w-[2px] origin-bottom rounded-2xl bg-accent"
          style={{ height: `${size * 0.4}px`, transformOrigin: "50% 0%" }}
          animate={{ rotate: 180 + secondDegrees }}
          transition={{ duration: animationDuration }}
        />
        <div className="absolute h-[10px] w-[10px] rounded-full bg-accent" />
      </div>
    </div>
  );
}
 
interface ListElementProp {
  name: string;
  city: string;
  country: string;
  timeDifference: string;
  pfp: string;
  onSelect: (name: string, timeDifference: string) => void;
  onHover: (name: string | null, timeDifference: string | null) => void;
  isSelected: boolean;
  isHovered: boolean;
  currentTime: Date;
  animationDuration: number;
  accentColor: string;
  textColor: string;
  hoverBackgroundColor: string;
}
 
function ListElement(props: ListElementProp) {
  const [isHovered, setIsHovered] = useState(false);
  const [isMobile, setIsMobile] = useState(false);
 
  useEffect(() => {
    const checkMobile = () => {
      setIsMobile(window.innerWidth < 768);
    };
 
    checkMobile();
    window.addEventListener("resize", checkMobile);
 
    return () => window.removeEventListener("resize", checkMobile);
  }, []);
 
  const handleEnter = () => {
    if (!isMobile) {
      setIsHovered(true);
      props.onHover(props.name, props.timeDifference);
    }
  };
 
  const handleLeave = () => {
    if (!isMobile) {
      setIsHovered(false);
      props.onHover(null, null);
    }
  };
 
  const handleClick = () => {
    props.onSelect(props.name, props.timeDifference);
  };
 
  const localTime = useMemo(() => {
    const hourDifference = parseInt(props.timeDifference);
    const newTime = new Date(props.currentTime);
    newTime.setHours(newTime.getHours() + hourDifference);
    return newTime.toLocaleTimeString([], {
      hour: "2-digit",
      minute: "2-digit",
    });
  }, [props.currentTime, props.timeDifference]);
 
  return (
    <motion.div
      className={cn(
        "flex cursor-pointer items-center rounded-lg p-3",
        props.isSelected || isHovered ? "bg-opacity-10" : "",
      )}
      onMouseEnter={handleEnter}
      onMouseLeave={handleLeave}
      onClick={handleClick}
      animate={{
        boxShadow: props.isSelected ? "inset 0 2px 4px 0 rgba(0, 0, 0, 0.06)" : "none",
        backgroundColor: props.isSelected || isHovered ? props.hoverBackgroundColor : "transparent",
      }}
      transition={{ duration: props.animationDuration }}
      style={{ color: props.textColor }}
    >
      <img src={props.pfp} alt={props.name} className="mr-4 h-10 w-10 rounded-full" />
      <div className="flex-grow">
        <div className="flex items-center justify-between">
          <span className="text-lg font-semibold">{props.name}</span>
          <div className="relative text-sm">
            <AnimatePresence>
              {!props.isSelected && !isHovered && (
                <motion.div
                  initial={{ opacity: 0, y: 10 }}
                  animate={{ opacity: 1, y: 0 }}
                  exit={{ opacity: 0, y: -10 }}
                  transition={{ duration: props.animationDuration }}
                >
                  {localTime}
                </motion.div>
              )}
              {(props.isSelected || isHovered) && (
                <motion.div
                  className={"whitespace-nowrap text-xs sm:text-sm"}
                  style={{ color: parseInt(props.timeDifference) < 0 ? "#EF4444" : "#10B981" }}
                  initial={{ opacity: 0, y: 10 }}
                  animate={{ opacity: 1, y: 0 }}
                  exit={{ opacity: 0, y: -10 }}
                  transition={{ duration: props.animationDuration }}
                >
                  {parseInt(props.timeDifference) === 0
                    ? "+ 0 Hours"
                    : `${props.timeDifference} Hours`}
                </motion.div>
              )}
            </AnimatePresence>
          </div>
        </div>
        <div
          className="text-sm"
          style={{ color: props.textColor }}
        >{`${props.city}, ${props.country}`}</div>
      </div>
    </motion.div>
  );
}
 
type ToggleButtonProps = {
  onClick: (isToggled: boolean) => void;
  accentColor: string;
  textColor: string;
};
 
function ToggleButton({ onClick, accentColor, textColor, ...props }: ToggleButtonProps) {
  const [isToggled, setIsToggled] = useState(false);
 
  const handleClick = () => {
    setIsToggled(!isToggled);
    onClick(!isToggled);
  };
 
  return (
    <motion.button
      className="relative inline-flex cursor-pointer items-center"
      onClick={handleClick}
      {...props}
    >
      <span className="mr-3 text-sm font-medium" style={{ color: textColor }}>
        {isToggled ? "Hide List" : "Show List"}
      </span>
      <div className="relative">
        <motion.div
          className="h-6 w-11 rounded-full bg-gray-200"
          animate={{ backgroundColor: isToggled ? accentColor : "#D1D5DB" }}
          transition={{ duration: 0.3 }}
        />
        <motion.div
          className="absolute left-0.5 top-0.5 h-5 w-5 rounded-full bg-white shadow"
          animate={{ x: isToggled ? 20 : 0 }}
          transition={{ duration: 0.3 }}
        />
      </div>
    </motion.button>
  );
}

Credits

Built by Arjun Vijay Prakash.