"use client"; import * as React from "react"; import { useGSAP } from "@gsap/react"; import gsap from "gsap"; import { cn } from "@/lib/utils"; import { useTimeSource } from "./use-time-source"; gsap.registerPlugin(useGSAP); export type FlipClockMode = "clock" | "countdown"; export type FlipDirection = "down" | "up"; export interface FlipClockProps { mode?: FlipClockMode; duration?: number; targetDate?: Date | string | number; date?: Date | string | number; running?: boolean; hourFormat?: "12" | "24"; timeZone?: string; showSeconds?: boolean; showPeriod?: boolean; showDays?: boolean; showLabels?: boolean; padHours?: boolean; flipping?: boolean; flipDirection?: FlipDirection; flipDuration?: number; bounce?: number; shadowIntensity?: number; perspective?: number; cardSize?: number; gap?: number; borderRadius?: number; showSeparator?: boolean; showHinges?: boolean; theme?: "dark" | "light"; panelColor?: string; textColor?: string; fontFamily?: string; className?: string; ariaLabel?: string; onComplete?: () => void; } const bounded = (value: number, fallback: number, min: number, max: number) => Number.isFinite(value) ? Math.min(max, Math.max(min, value)) : fallback; type FlipUnitProps = { value: string; label: string; period?: string; separator: boolean; options: { reducedMotion: boolean } & Required< Pick< FlipClockProps, | "flipping" | "flipDirection" | "flipDuration" | "bounce" | "shadowIntensity" | "perspective" | "borderRadius" | "showHinges" | "showLabels" | "fontFamily" | "cardSize" > >; }; function FlipUnit({ value, label, period, separator, options }: FlipUnitProps) { const rootRef = React.useRef(null); const previousRef = React.useRef(null); const { flipping, flipDirection, flipDuration, bounce, shadowIntensity, perspective, borderRadius, showHinges, showLabels, fontFamily, cardSize, reducedMotion, } = options; useGSAP( () => { const root = rootRef.current; if (!root) return; const top = root.querySelector("[data-flip-static-top]"); const bottom = root.querySelector( "[data-flip-static-bottom]", ); const outgoing = root.querySelector("[data-flip-outgoing]"); const incoming = root.querySelector("[data-flip-incoming]"); const outgoingNumber = outgoing?.querySelector("[data-flip-number]"); const incomingNumber = incoming?.querySelector("[data-flip-number]"); const outgoingShade = outgoing?.querySelector("[data-flip-shade]"); const incomingShade = incoming?.querySelector("[data-flip-shade]"); if ( !top || !bottom || !outgoing || !incoming || !outgoingNumber || !incomingNumber || !outgoingShade || !incomingShade ) return; const oldValue = previousRef.current; previousRef.current = value; const reset = () => { top.textContent = value; bottom.textContent = value; gsap.set([outgoing, incoming], { visibility: "hidden", rotationX: 0 }); }; reset(); if ( !flipping || reducedMotion || oldValue === null || oldValue === "--" || oldValue === value || value === "--" ) return; const down = flipDirection === "down"; const time = bounded(flipDuration, 0.65, 0.15, 0.9); const shade = bounded(shadowIntensity, 0.28, 0, 0.7); (down ? bottom : top).textContent = oldValue; outgoingNumber.textContent = oldValue; incomingNumber.textContent = value; gsap.set(outgoing, { visibility: "visible", rotationX: 0 }); gsap.set(incoming, { visibility: "hidden", rotationX: down ? 90 : -90, }); gsap.set(outgoingShade, { opacity: 0 }); gsap.set(incomingShade, { opacity: shade }); const settle = bounded(bounce, 0.12, 0, 0.4); gsap .timeline() .to( outgoing, { rotationX: down ? -90 : 90, duration: time * 0.46, ease: "power2.in", }, 0, ) .to( outgoingShade, { opacity: shade, duration: time * 0.46, ease: "power1.in" }, 0, ) .set(outgoing, { visibility: "hidden" }, time * 0.46) .set(incoming, { visibility: "visible" }, time * 0.46) .to( incoming, { rotationX: 0, duration: time * 0.54, ease: settle ? `back.out(${settle * 5})` : "power3.out", }, time * 0.46, ) .to( incomingShade, { opacity: 0, duration: time * 0.54, ease: "power2.out" }, time * 0.46, ) .set([outgoing, incoming], { visibility: "hidden", rotationX: 0 }, time) .call( () => { top.textContent = value; bottom.textContent = value; }, [], time, ); }, { scope: rootRef, dependencies: [ value, reducedMotion, flipping, flipDirection, flipDuration, bounce, shadowIntensity, ], revertOnUpdate: true, }, ); const half = (bottom: boolean, part: "static" | "outgoing" | "incoming") => { const moving = part !== "static"; const corner = `min(${borderRadius}px, ${(borderRadius / cardSize) * 100}cqw)`; return (
{value} {moving && ( )}
); }; return ( ); } export function FlipClock({ mode = "clock", duration = 3600, targetDate, date, running = true, hourFormat = "12", timeZone, showSeconds = mode === "countdown", showPeriod = true, showDays = false, showLabels = false, padHours = false, flipping = true, flipDirection = "down", flipDuration = 0.65, bounce = 0.12, shadowIntensity = 0.28, perspective = 900, cardSize = 280, gap = 16, borderRadius = 14, showSeparator = false, showHinges = false, theme = "dark", panelColor, textColor, fontFamily = "Arial, Helvetica, sans-serif", className, ariaLabel, onComplete, }: FlipClockProps) { const rootRef = React.useRef(null); const [reducedMotion, setReducedMotion] = React.useState(false); useGSAP( () => { const media = gsap.matchMedia(); media.add( { reduced: "(prefers-reduced-motion: reduce)", standard: "(prefers-reduced-motion: no-preference)", }, (context) => { setReducedMotion(Boolean(context.conditions?.reduced)); }, ); return () => media.revert(); }, { scope: rootRef }, ); const { time, remaining } = useTimeSource({ mode, duration, targetDate, date, running, onComplete, }); const formatter = React.useMemo(() => { try { return new Intl.DateTimeFormat("en-GB", { hour: "2-digit", minute: "2-digit", second: "2-digit", hourCycle: "h23", timeZone: timeZone || undefined, }); } catch { return new Intl.DateTimeFormat("en-GB", { hour: "2-digit", minute: "2-digit", second: "2-digit", hourCycle: "h23", }); } }, [timeZone]); const units: { value: string; label: string }[] = []; let period: string | undefined; const pad = (value: number) => String(value).padStart(2, "0"); if (mode === "countdown") { const days = remaining === null ? null : Math.floor(remaining / 86400); const hours = remaining === null ? null : Math.floor(remaining / 3600) % (showDays ? 24 : Infinity); if (showDays) units.push({ value: days === null ? "--" : pad(days), label: "Days" }); units.push({ value: hours === null ? "--" : pad(hours), label: "Hours" }); units.push({ value: remaining === null ? "--" : pad(Math.floor(remaining / 60) % 60), label: "Minutes", }); if (showSeconds) units.push({ value: remaining === null ? "--" : pad(remaining % 60), label: "Seconds", }); } else { const parts = time === null ? null : Object.fromEntries( formatter .formatToParts(new Date(time)) .map((part) => [part.type, part.value]), ); const hour = parts ? Number(parts.hour) : null; const displayHour = hour === null ? null : hourFormat === "12" ? hour % 12 || 12 : hour; if (hour !== null && showPeriod && hourFormat === "12") period = hour < 12 ? "AM" : "PM"; units.push({ value: displayHour === null ? "--" : hourFormat === "24" || padHours ? pad(displayHour) : String(displayHour), label: "Hours", }); units.push({ value: parts?.minute ?? "--", label: "Minutes" }); if (showSeconds) units.push({ value: parts?.second ?? "--", label: "Seconds" }); } const size = bounded(cardSize, 280, 64, 600); const spacing = bounded(gap, 16, 0, 48); const width = size * units.length + spacing * (units.length - 1); const label = ariaLabel ?? (mode === "clock" ? `Current time: ${units.map((unit) => unit.value).join(":")}${period ? ` ${period}` : ""}` : `Countdown: ${units.map((unit) => `${unit.value} ${unit.label.toLowerCase()}`).join(", ")}`); const options = { reducedMotion, flipping, flipDirection, flipDuration, bounce, shadowIntensity, perspective, borderRadius: bounded(borderRadius, 14, 0, 40), showHinges, showLabels, fontFamily, cardSize: size, }; return (
{units.map((unit, index) => ( ))}
{mode === "countdown" && remaining === 0 ? "Countdown complete" : ""}
); } export default FlipClock;