Clock Widgets

PreviousNext

Clock widgets including analog, digital, world clock, stopwatch, and timer with liquid glass styling.

Docs
einuicomponent

Preview

Loading preview…
registry/widgets/clock-widget.tsx
"use client";

import * as React from "react";
import { cn } from "@/lib/utils";
import { Sun, Moon, Play, Pause, RotateCcw } from "lucide-react";
import { GlassWidgetBase } from "./base-widget";

interface AnalogClockWidgetProps {
  time?: Date;
  showNumbers?: boolean;
  size?: "sm" | "md" | "lg";
  className?: string;
}

function AnalogClockWidget({
  time,
  showNumbers = true,
  size = "md",
  className,
}: AnalogClockWidgetProps) {
  const [currentTime, setCurrentTime] = React.useState<Date | undefined>(time);

  React.useEffect(() => {
    if (time) {
      setCurrentTime(time);
      return;
    }
    setCurrentTime(new Date());
    const interval = setInterval(() => setCurrentTime(new Date()), 1000);
    return () => clearInterval(interval);
  }, [time]);

  if (!currentTime) return null;

  const seconds = currentTime.getSeconds();
  const minutes = currentTime.getMinutes();
  const hours = currentTime.getHours() % 12;

  const secondDegrees = seconds * 6;
  const minuteDegrees = minutes * 6 + seconds * 0.1;
  const hourDegrees = hours * 30 + minutes * 0.5;

  const sizeConfig = {
    sm: { container: "size-24", numbers: "text-[10px]", radius: 36 },
    md: { container: "size-32", numbers: "text-xs", radius: 42 },
    lg: { container: "size-36", numbers: "text-sm", radius: 42 },
  };

  const config = sizeConfig[size];
  const numbers = showNumbers ? [12, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11] : [];

  return (
    <GlassWidgetBase className={cn("p-3", className)} size="sm" glowColor="blue">
      <div className={cn("relative", config.container)}>
        {/* Clock face with glass effect */}
        <div className="absolute inset-0 rounded-full border border-white/20  bg-white/5 backdrop-blur-sm shadow-inner" />

        {numbers.map((num, i) => {
          const angle = (i * 30 - 90) * (Math.PI / 180);
          const x = 50 + config.radius * Math.cos(angle);
          const y = 50 + config.radius * Math.sin(angle);
          return (
            <span
              key={num}
              className={cn("absolute text-white/70 font-light", config.numbers)}
              style={{
                left: `${x}%`,
                top: `${y}%`,
                transform: "translate(-50%, -50%)",
              }}
            >
              {num}
            </span>
          );
        })}

        {!showNumbers &&
          Array.from({ length: 12 }).map((_, i) => {
            const angle = (i * 30 - 90) * (Math.PI / 180);
            return (
              <div
                key={i}
                className="absolute size-1.5 rounded-full bg-white/40"
                style={{
                  left: `${50 + config.radius * Math.cos(angle)}%`,
                  top: `${50 + config.radius * Math.sin(angle)}%`,
                  transform: "translate(-50%, -50%)",
                }}
              />
            );
          })}

        {/* Center dot */}
        <div className="absolute left-1/2 top-1/2 w-2.5 h-2.5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-white/90 z-20 shadow-lg" />

        {/* Hour hand */}
        <div
          className="absolute left-1/2 top-1/2 w-1 rounded-full bg-white/90 origin-bottom shadow-md"
          style={{
            height: size === "lg" ? "28px" : size === "md" ? "22px" : "18px",
            transform: `translateX(-50%) translateY(-100%) rotate(${hourDegrees}deg)`,
          }}
        />

        {/* Minute hand */}
        <div
          className="absolute left-1/2 top-1/2 w-0.5 rounded-full bg-white/80 origin-bottom shadow-sm"
          style={{
            height: size === "lg" ? "36px" : size === "md" ? "28px" : "22px",
            transform: `translateX(-50%) translateY(-100%) rotate(${minuteDegrees}deg)`,
          }}
        />

        {/* Second hand */}
        <div
          className="absolute left-1/2 top-1/2 w-px bg-cyan-400 origin-bottom"
          style={{
            height: size === "lg" ? "40px" : size === "md" ? "32px" : "26px",
            transform: `translateX(-50%) translateY(-100%) rotate(${secondDegrees}deg)`,
          }}
        />
      </div>
    </GlassWidgetBase>
  );
}

interface DigitalClockWidgetProps {
  time?: Date;
  showSeconds?: boolean;
  format?: "12h" | "24h";
  className?: string;
}

function DigitalClockWidget({
  time,
  showSeconds = true,
  format = "12h",
  className,
}: DigitalClockWidgetProps) {
  const [currentTime, setCurrentTime] = React.useState<Date | undefined>(time);

  React.useEffect(() => {
    if (time) {
      setCurrentTime(time);
      return;
    }
    setCurrentTime(new Date());
    const interval = setInterval(() => setCurrentTime(new Date()), 1000);
    return () => clearInterval(interval);
  }, [time]);

  if (!currentTime) return null;

  const hours = currentTime.getHours();
  const minutes = currentTime.getMinutes();
  const seconds = currentTime.getSeconds();

  const displayHours = format === "12h" ? hours % 12 || 12 : hours;
  const period = hours >= 12 ? "PM" : "AM";

  return (
    <GlassWidgetBase
      className={cn("flex flex-col items-center justify-center min-w-35", className)}
      glowColor="cyan"
    >
      <div className="flex items-baseline gap-1">
        <span className="text-4xl font-light text-white tabular-nums">
          {displayHours.toString().padStart(2, "0")}:{minutes.toString().padStart(2, "0")}
        </span>
        {showSeconds && (
          <span className="text-xl text-white/60 tabular-nums">
            :{seconds.toString().padStart(2, "0")}
          </span>
        )}
        {format === "12h" && <span className="text-sm text-white/50 ml-1">{period}</span>}
      </div>
    </GlassWidgetBase>
  );
}

interface WorldClockWidgetProps {
  clocks: Array<{
    city: string;
    timezone: string;
    isDay?: boolean;
  }>;
  className?: string;
}

function WorldClockWidget({ clocks, className }: WorldClockWidgetProps) {
  const [times, setTimes] = React.useState<string[]>([]);

  React.useEffect(() => {
    const updateTimes = () => {
      const newTimes = clocks.map((clock) => {
        try {
          return new Date().toLocaleTimeString("en-US", {
            timeZone: clock.timezone,
            hour: "numeric",
            minute: "2-digit",
            hour12: true,
          });
        } catch {
          return "--:--";
        }
      });
      setTimes(newTimes);
    };
    updateTimes();
    const interval = setInterval(updateTimes, 1000);
    return () => clearInterval(interval);
  }, [clocks]);

  return (
    <GlassWidgetBase className={cn("min-w-45", className)} glowColor="blue">
      <div className="space-y-3">
        {clocks.map((clock, i) => (
          <div key={i} className="flex items-center justify-between">
            <div className="flex items-center gap-2">
              <span className="text-white font-medium">{clock.city}</span>
              {clock.isDay !== undefined &&
                (clock.isDay ? (
                  <Sun className="w-4 h-4 text-amber-400" />
                ) : (
                  <Moon className="w-4 h-4 text-blue-300" />
                ))}
            </div>
            <span className="text-white/80 text-lg tabular-nums">{times[i] || "--:--"}</span>
          </div>
        ))}
      </div>
    </GlassWidgetBase>
  );
}

interface StopwatchWidgetProps {
  className?: string;
}

function StopwatchWidget({ className }: StopwatchWidgetProps) {
  const [time, setTime] = React.useState(0);
  const [isRunning, setIsRunning] = React.useState(false);

  React.useEffect(() => {
    let interval: NodeJS.Timeout;
    if (isRunning) {
      interval = setInterval(() => setTime((t) => t + 10), 10);
    }
    return () => clearInterval(interval);
  }, [isRunning]);

  const formatTime = (ms: number) => {
    const minutes = Math.floor(ms / 60000);
    const seconds = Math.floor((ms % 60000) / 1000);
    const centiseconds = Math.floor((ms % 1000) / 10);
    return `${minutes.toString().padStart(2, "0")}:${seconds
      .toString()
      .padStart(2, "0")}.${centiseconds.toString().padStart(2, "0")}`;
  };

  const reset = () => {
    setIsRunning(false);
    setTime(0);
  };

  return (
    <GlassWidgetBase className={cn("min-w-40", className)} glowColor="cyan">
      <div className="text-3xl font-light text-white text-center mb-4 tabular-nums">
        {formatTime(time)}
      </div>
      <div className="flex items-center justify-center gap-3">
        <button
          onClick={reset}
          className="p-2.5 rounded-full bg-white/10 hover:bg-white/20 text-white/60 hover:text-white transition-colors"
          aria-label="Reset"
        >
          <RotateCcw className="w-4 h-4" />
        </button>
        <button
          onClick={() => setIsRunning(!isRunning)}
          className={cn(
            "p-3 rounded-full transition-colors",
            isRunning
              ? "bg-red-500/20 hover:bg-red-500/30 text-red-400"
              : "bg-emerald-500/20 hover:bg-emerald-500/30 text-emerald-400"
          )}
          aria-label={isRunning ? "Pause" : "Start"}
        >
          {isRunning ? <Pause className="w-5 h-5" /> : <Play className="w-5 h-5 ml-0.5" />}
        </button>
      </div>
    </GlassWidgetBase>
  );
}

interface TimerWidgetProps {
  initialMinutes?: number;
  className?: string;
}

function TimerWidget({ initialMinutes = 5, className }: TimerWidgetProps) {
  const [timeLeft, setTimeLeft] = React.useState(initialMinutes * 60 * 1000);
  const [isRunning, setIsRunning] = React.useState(false);
  const [initialTime] = React.useState(initialMinutes * 60 * 1000);

  React.useEffect(() => {
    let interval: NodeJS.Timeout;
    if (isRunning && timeLeft > 0) {
      interval = setInterval(() => setTimeLeft((t) => Math.max(0, t - 1000)), 1000);
    }
    return () => clearInterval(interval);
  }, [isRunning, timeLeft]);

  const formatTime = (ms: number) => {
    const minutes = Math.floor(ms / 60000);
    const seconds = Math.floor((ms % 60000) / 1000);
    return `${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
  };

  const reset = () => {
    setIsRunning(false);
    setTimeLeft(initialTime);
  };

  const progress = (timeLeft / initialTime) * 100;

  return (
    <GlassWidgetBase
      className={cn("min-w-40", className)}
      glowColor={timeLeft === 0 ? "red" : "green"}
    >
      <div className="relative flex items-center justify-center mb-4">
        <svg className="w-24 h-24 -rotate-90">
          <circle
            cx="48"
            cy="48"
            r="44"
            stroke="rgba(255,255,255,0.1)"
            strokeWidth="4"
            fill="none"
          />
          <circle
            cx="48"
            cy="48"
            r="44"
            stroke={timeLeft === 0 ? "#ef4444" : "#22c55e"}
            strokeWidth="4"
            fill="none"
            strokeLinecap="round"
            strokeDasharray={276.46}
            strokeDashoffset={276.46 * (1 - progress / 100)}
            className="transition-all duration-1000"
          />
        </svg>
        <div className="absolute text-2xl font-light text-white tabular-nums">
          {formatTime(timeLeft)}
        </div>
      </div>
      <div className="flex items-center justify-center gap-3">
        <button
          onClick={reset}
          className="p-2.5 rounded-full bg-white/10 hover:bg-white/20 text-white/60 hover:text-white transition-colors"
          aria-label="Reset"
        >
          <RotateCcw className="w-4 h-4" />
        </button>
        <button
          onClick={() => setIsRunning(!isRunning)}
          className={cn(
            "p-3 rounded-full transition-colors",
            isRunning
              ? "bg-red-500/20 hover:bg-red-500/30 text-red-400"
              : "bg-emerald-500/20 hover:bg-emerald-500/30 text-emerald-400"
          )}
          aria-label={isRunning ? "Pause" : "Start"}
        >
          {isRunning ? <Pause className="w-5 h-5" /> : <Play className="w-5 h-5 ml-0.5" />}
        </button>
      </div>
    </GlassWidgetBase>
  );
}

export { AnalogClockWidget, DigitalClockWidget, WorldClockWidget, StopwatchWidget, TimerWidget };

Installation

npx shadcn@latest add @einui/clock-widget

Usage

import { ClockWidget } from "@/components/clock-widget"
<ClockWidget />