{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "flip-clock",
  "type": "registry:ui",
  "title": "Flip Clock",
  "description": "A realistic hinged flip clock and countdown with smooth 3D motion, moving shadows, and configurable time displays.",
  "dependencies": [
    "gsap",
    "@gsap/react",
    "clsx",
    "tailwind-merge"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "src/registry/ui/flip-clock.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { useGSAP } from \"@gsap/react\";\nimport gsap from \"gsap\";\nimport { cn } from \"@/lib/utils\";\nimport { useTimeSource } from \"./use-time-source\";\n\ngsap.registerPlugin(useGSAP);\n\nexport type FlipClockMode = \"clock\" | \"countdown\";\nexport type FlipDirection = \"down\" | \"up\";\n\nexport interface FlipClockProps {\n  mode?: FlipClockMode;\n  duration?: number;\n  targetDate?: Date | string | number;\n  date?: Date | string | number;\n  running?: boolean;\n  hourFormat?: \"12\" | \"24\";\n  timeZone?: string;\n  showSeconds?: boolean;\n  showPeriod?: boolean;\n  showDays?: boolean;\n  showLabels?: boolean;\n  padHours?: boolean;\n  flipping?: boolean;\n  flipDirection?: FlipDirection;\n  flipDuration?: number;\n  bounce?: number;\n  shadowIntensity?: number;\n  perspective?: number;\n  cardSize?: number;\n  gap?: number;\n  borderRadius?: number;\n  showSeparator?: boolean;\n  showHinges?: boolean;\n  theme?: \"dark\" | \"light\";\n  panelColor?: string;\n  textColor?: string;\n  fontFamily?: string;\n  className?: string;\n  ariaLabel?: string;\n  onComplete?: () => void;\n}\n\nconst bounded = (value: number, fallback: number, min: number, max: number) =>\n  Number.isFinite(value) ? Math.min(max, Math.max(min, value)) : fallback;\n\ntype FlipUnitProps = {\n  value: string;\n  label: string;\n  period?: string;\n  separator: boolean;\n  options: { reducedMotion: boolean } & Required<\n    Pick<\n      FlipClockProps,\n      | \"flipping\"\n      | \"flipDirection\"\n      | \"flipDuration\"\n      | \"bounce\"\n      | \"shadowIntensity\"\n      | \"perspective\"\n      | \"borderRadius\"\n      | \"showHinges\"\n      | \"showLabels\"\n      | \"fontFamily\"\n      | \"cardSize\"\n    >\n  >;\n};\n\nfunction FlipUnit({ value, label, period, separator, options }: FlipUnitProps) {\n  const rootRef = React.useRef<HTMLDivElement>(null);\n  const previousRef = React.useRef<string | null>(null);\n  const {\n    flipping,\n    flipDirection,\n    flipDuration,\n    bounce,\n    shadowIntensity,\n    perspective,\n    borderRadius,\n    showHinges,\n    showLabels,\n    fontFamily,\n    cardSize,\n    reducedMotion,\n  } = options;\n\n  useGSAP(\n    () => {\n      const root = rootRef.current;\n      if (!root) return;\n      const top = root.querySelector<HTMLElement>(\"[data-flip-static-top]\");\n      const bottom = root.querySelector<HTMLElement>(\n        \"[data-flip-static-bottom]\",\n      );\n      const outgoing = root.querySelector<HTMLElement>(\"[data-flip-outgoing]\");\n      const incoming = root.querySelector<HTMLElement>(\"[data-flip-incoming]\");\n      const outgoingNumber =\n        outgoing?.querySelector<HTMLElement>(\"[data-flip-number]\");\n      const incomingNumber =\n        incoming?.querySelector<HTMLElement>(\"[data-flip-number]\");\n      const outgoingShade =\n        outgoing?.querySelector<HTMLElement>(\"[data-flip-shade]\");\n      const incomingShade =\n        incoming?.querySelector<HTMLElement>(\"[data-flip-shade]\");\n      if (\n        !top ||\n        !bottom ||\n        !outgoing ||\n        !incoming ||\n        !outgoingNumber ||\n        !incomingNumber ||\n        !outgoingShade ||\n        !incomingShade\n      )\n        return;\n      const oldValue = previousRef.current;\n      previousRef.current = value;\n      const reset = () => {\n        top.textContent = value;\n        bottom.textContent = value;\n        gsap.set([outgoing, incoming], { visibility: \"hidden\", rotationX: 0 });\n      };\n      reset();\n      if (\n        !flipping ||\n        reducedMotion ||\n        oldValue === null ||\n        oldValue === \"--\" ||\n        oldValue === value ||\n        value === \"--\"\n      )\n        return;\n      const down = flipDirection === \"down\";\n      const time = bounded(flipDuration, 0.65, 0.15, 0.9);\n      const shade = bounded(shadowIntensity, 0.28, 0, 0.7);\n      (down ? bottom : top).textContent = oldValue;\n      outgoingNumber.textContent = oldValue;\n      incomingNumber.textContent = value;\n      gsap.set(outgoing, { visibility: \"visible\", rotationX: 0 });\n      gsap.set(incoming, {\n        visibility: \"hidden\",\n        rotationX: down ? 90 : -90,\n      });\n      gsap.set(outgoingShade, { opacity: 0 });\n      gsap.set(incomingShade, { opacity: shade });\n      const settle = bounded(bounce, 0.12, 0, 0.4);\n      gsap\n        .timeline()\n        .to(\n          outgoing,\n          {\n            rotationX: down ? -90 : 90,\n            duration: time * 0.46,\n            ease: \"power2.in\",\n          },\n          0,\n        )\n        .to(\n          outgoingShade,\n          { opacity: shade, duration: time * 0.46, ease: \"power1.in\" },\n          0,\n        )\n        .set(outgoing, { visibility: \"hidden\" }, time * 0.46)\n        .set(incoming, { visibility: \"visible\" }, time * 0.46)\n        .to(\n          incoming,\n          {\n            rotationX: 0,\n            duration: time * 0.54,\n            ease: settle ? `back.out(${settle * 5})` : \"power3.out\",\n          },\n          time * 0.46,\n        )\n        .to(\n          incomingShade,\n          { opacity: 0, duration: time * 0.54, ease: \"power2.out\" },\n          time * 0.46,\n        )\n        .set([outgoing, incoming], { visibility: \"hidden\", rotationX: 0 }, time)\n        .call(\n          () => {\n            top.textContent = value;\n            bottom.textContent = value;\n          },\n          [],\n          time,\n        );\n    },\n    {\n      scope: rootRef,\n      dependencies: [\n        value,\n        reducedMotion,\n        flipping,\n        flipDirection,\n        flipDuration,\n        bounce,\n        shadowIntensity,\n      ],\n      revertOnUpdate: true,\n    },\n  );\n\n  const half = (bottom: boolean, part: \"static\" | \"outgoing\" | \"incoming\") => {\n    const moving = part !== \"static\";\n    const corner = `min(${borderRadius}px, ${(borderRadius / cardSize) * 100}cqw)`;\n    return (\n      <div\n        data-flip-outgoing={part === \"outgoing\" ? \"\" : undefined}\n        data-flip-incoming={part === \"incoming\" ? \"\" : undefined}\n        className={cn(\n          \"absolute inset-x-0 h-1/2 overflow-hidden bg-(--flip-panel)\",\n          bottom ? \"bottom-0\" : \"top-0\",\n          moving ? \"z-20 will-change-transform\" : \"z-0\",\n        )}\n        style={{\n          borderRadius: bottom\n            ? `0 0 ${corner} ${corner}`\n            : `${corner} ${corner} 0 0`,\n          transformOrigin: bottom ? \"50% 0%\" : \"50% 100%\",\n          backfaceVisibility: \"hidden\",\n          visibility: moving ? \"hidden\" : undefined,\n        }}\n      >\n        <span\n          data-flip-static-top={part === \"static\" && !bottom ? \"\" : undefined}\n          data-flip-static-bottom={part === \"static\" && bottom ? \"\" : undefined}\n          data-flip-number\n          className=\"absolute inset-x-0 flex h-[200%] items-center justify-center font-bold leading-none tracking-[-0.065em] text-(--flip-text) tabular-nums\"\n          style={{\n            top: bottom ? \"-100%\" : 0,\n            fontSize: `${58 * Math.min(1, 2 / Math.max(2, value.length))}cqw`,\n            fontFamily,\n            opacity: bottom ? 1 : 0.86,\n          }}\n        >\n          {value}\n        </span>\n        <span className=\"pointer-events-none absolute inset-0 bg-linear-to-b from-black/3 to-transparent\" />\n        {moving && (\n          <span\n            data-flip-shade\n            className=\"pointer-events-none absolute inset-0 bg-black\"\n            style={{ opacity: 0 }}\n          />\n        )}\n      </div>\n    );\n  };\n\n  return (\n    <div className=\"relative min-w-0 flex-1\" aria-hidden=\"true\">\n      <div\n        ref={rootRef}\n        data-slot=\"flip-clock-unit\"\n        data-value={value}\n        className=\"relative isolate aspect-[1.14] w-full @container\"\n        style={{ perspective: bounded(perspective, 900, 250, 2000) }}\n      >\n        {half(false, \"static\")}\n        {half(true, \"static\")}\n        {half(flipDirection === \"up\", \"outgoing\")}\n        {half(flipDirection === \"down\", \"incoming\")}\n        {period && (\n          <span\n            className=\"absolute left-[6%] top-[6%] z-30 font-bold leading-none text-(--flip-text) opacity-55\"\n            style={{ fontSize: \"7cqw\", fontFamily }}\n          >\n            {period}\n          </span>\n        )}\n        <span\n          data-slot=\"flip-clock-seam\"\n          className=\"pointer-events-none absolute inset-x-0 top-1/2 z-30 -translate-y-1/2 bg-black/90\"\n          style={{ height: `min(1.5px, ${(1.5 / cardSize) * 100}cqw)` }}\n        />\n        {showHinges && (\n          <>\n            <span\n              data-slot=\"flip-clock-hinge\"\n              className=\"pointer-events-none absolute left-0 top-1/2 z-40 h-[9%] w-[2.5%] -translate-x-1/2 -translate-y-1/2 rounded-sm bg-linear-to-b from-zinc-500 via-zinc-700 to-zinc-950 shadow-sm\"\n            />\n            <span\n              data-slot=\"flip-clock-hinge\"\n              className=\"pointer-events-none absolute right-0 top-1/2 z-40 h-[9%] w-[2.5%] translate-x-1/2 -translate-y-1/2 rounded-sm bg-linear-to-b from-zinc-500 via-zinc-700 to-zinc-950 shadow-sm\"\n            />\n          </>\n        )}\n      </div>\n      {separator && (\n        <span className=\"pointer-events-none absolute left-full top-0 z-40 aspect-[1.14] w-full text-(--flip-text) opacity-45\">\n          <span\n            data-slot=\"flip-clock-separator\"\n            className=\"flex h-full items-center justify-center\"\n            style={{ width: \"var(--flip-gap)\" }}\n          >\n            <svg\n              viewBox=\"0 0 16 40\"\n              className=\"aspect-2/5 w-full max-w-4 shrink-0 fill-current\"\n              aria-hidden=\"true\"\n            >\n              <circle cx=\"8\" cy=\"10\" r=\"3\" />\n              <circle cx=\"8\" cy=\"30\" r=\"3\" />\n            </svg>\n          </span>\n        </span>\n      )}\n      {showLabels && (\n        <span className=\"mt-3 block text-center text-[clamp(9px,1.5cqw,11px)] font-medium uppercase tracking-[0.16em] text-(--flip-text) opacity-45\">\n          {label}\n        </span>\n      )}\n    </div>\n  );\n}\n\nexport function FlipClock({\n  mode = \"clock\",\n  duration = 3600,\n  targetDate,\n  date,\n  running = true,\n  hourFormat = \"12\",\n  timeZone,\n  showSeconds = mode === \"countdown\",\n  showPeriod = true,\n  showDays = false,\n  showLabels = false,\n  padHours = false,\n  flipping = true,\n  flipDirection = \"down\",\n  flipDuration = 0.65,\n  bounce = 0.12,\n  shadowIntensity = 0.28,\n  perspective = 900,\n  cardSize = 280,\n  gap = 16,\n  borderRadius = 14,\n  showSeparator = false,\n  showHinges = false,\n  theme = \"dark\",\n  panelColor,\n  textColor,\n  fontFamily = \"Arial, Helvetica, sans-serif\",\n  className,\n  ariaLabel,\n  onComplete,\n}: FlipClockProps) {\n  const rootRef = React.useRef<HTMLDivElement>(null);\n  const [reducedMotion, setReducedMotion] = React.useState(false);\n  useGSAP(\n    () => {\n      const media = gsap.matchMedia();\n      media.add(\n        {\n          reduced: \"(prefers-reduced-motion: reduce)\",\n          standard: \"(prefers-reduced-motion: no-preference)\",\n        },\n        (context) => {\n          setReducedMotion(Boolean(context.conditions?.reduced));\n        },\n      );\n      return () => media.revert();\n    },\n    { scope: rootRef },\n  );\n  const { time, remaining } = useTimeSource({\n    mode,\n    duration,\n    targetDate,\n    date,\n    running,\n    onComplete,\n  });\n  const formatter = React.useMemo(() => {\n    try {\n      return new Intl.DateTimeFormat(\"en-GB\", {\n        hour: \"2-digit\",\n        minute: \"2-digit\",\n        second: \"2-digit\",\n        hourCycle: \"h23\",\n        timeZone: timeZone || undefined,\n      });\n    } catch {\n      return new Intl.DateTimeFormat(\"en-GB\", {\n        hour: \"2-digit\",\n        minute: \"2-digit\",\n        second: \"2-digit\",\n        hourCycle: \"h23\",\n      });\n    }\n  }, [timeZone]);\n  const units: { value: string; label: string }[] = [];\n  let period: string | undefined;\n  const pad = (value: number) => String(value).padStart(2, \"0\");\n  if (mode === \"countdown\") {\n    const days = remaining === null ? null : Math.floor(remaining / 86400);\n    const hours =\n      remaining === null\n        ? null\n        : Math.floor(remaining / 3600) % (showDays ? 24 : Infinity);\n    if (showDays)\n      units.push({ value: days === null ? \"--\" : pad(days), label: \"Days\" });\n    units.push({ value: hours === null ? \"--\" : pad(hours), label: \"Hours\" });\n    units.push({\n      value: remaining === null ? \"--\" : pad(Math.floor(remaining / 60) % 60),\n      label: \"Minutes\",\n    });\n    if (showSeconds)\n      units.push({\n        value: remaining === null ? \"--\" : pad(remaining % 60),\n        label: \"Seconds\",\n      });\n  } else {\n    const parts =\n      time === null\n        ? null\n        : Object.fromEntries(\n            formatter\n              .formatToParts(new Date(time))\n              .map((part) => [part.type, part.value]),\n          );\n    const hour = parts ? Number(parts.hour) : null;\n    const displayHour =\n      hour === null ? null : hourFormat === \"12\" ? hour % 12 || 12 : hour;\n    if (hour !== null && showPeriod && hourFormat === \"12\")\n      period = hour < 12 ? \"AM\" : \"PM\";\n    units.push({\n      value:\n        displayHour === null\n          ? \"--\"\n          : hourFormat === \"24\" || padHours\n            ? pad(displayHour)\n            : String(displayHour),\n      label: \"Hours\",\n    });\n    units.push({ value: parts?.minute ?? \"--\", label: \"Minutes\" });\n    if (showSeconds)\n      units.push({ value: parts?.second ?? \"--\", label: \"Seconds\" });\n  }\n  const size = bounded(cardSize, 280, 64, 600);\n  const spacing = bounded(gap, 16, 0, 48);\n  const width = size * units.length + spacing * (units.length - 1);\n  const label =\n    ariaLabel ??\n    (mode === \"clock\"\n      ? `Current time: ${units.map((unit) => unit.value).join(\":\")}${period ? ` ${period}` : \"\"}`\n      : `Countdown: ${units.map((unit) => `${unit.value} ${unit.label.toLowerCase()}`).join(\", \")}`);\n  const options = {\n    reducedMotion,\n    flipping,\n    flipDirection,\n    flipDuration,\n    bounce,\n    shadowIntensity,\n    perspective,\n    borderRadius: bounded(borderRadius, 14, 0, 40),\n    showHinges,\n    showLabels,\n    fontFamily,\n    cardSize: size,\n  };\n\n  return (\n    <div\n      ref={rootRef}\n      role=\"timer\"\n      aria-live=\"off\"\n      aria-label={label}\n      data-slot=\"flip-clock\"\n      data-mode={mode}\n      className={cn(\"w-full @container\", className)}\n      style={\n        {\n          maxWidth: width,\n          \"--flip-gap\": `min(${spacing}px, max(3px, ${(spacing / width) * 100}cqw))`,\n          \"--flip-panel\":\n            panelColor ?? (theme === \"dark\" ? \"#111111\" : \"#e7e7e7\"),\n          \"--flip-text\":\n            textColor ?? (theme === \"dark\" ? \"#f8f8f8\" : \"#171717\"),\n        } as React.CSSProperties\n      }\n    >\n      <div className=\"flex items-start\" style={{ gap: \"var(--flip-gap)\" }}>\n        {units.map((unit, index) => (\n          <FlipUnit\n            key={unit.label}\n            {...unit}\n            period={unit.label === \"Hours\" ? period : undefined}\n            separator={showSeparator && index < units.length - 1}\n            options={options}\n          />\n        ))}\n      </div>\n      <span role=\"status\" className=\"sr-only\">\n        {mode === \"countdown\" && remaining === 0 ? \"Countdown complete\" : \"\"}\n      </span>\n    </div>\n  );\n}\n\nexport default FlipClock;\n",
      "type": "registry:ui",
      "target": "@ui/flip-clock.tsx"
    },
    {
      "path": "src/registry/ui/use-time-source.ts",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\nconst bounded = (value: number, fallback: number, min: number, max: number) =>\n  Number.isFinite(value) ? Math.min(max, Math.max(min, value)) : fallback;\n\nconst timestamp = (value: Date | string | number | undefined) =>\n  value === undefined ? undefined : new Date(value).getTime();\n\nexport function useTimeSource({\n  mode,\n  duration,\n  targetDate,\n  date,\n  running,\n  onComplete,\n}: {\n  mode: \"clock\" | \"countdown\";\n  duration: number;\n  targetDate?: Date | string | number;\n  date?: Date | string | number;\n  running: boolean;\n  onComplete?: () => void;\n}) {\n  const [snapshot, setSnapshot] = React.useState<{\n    time: number | null;\n    remaining: number | null;\n  }>({ time: null, remaining: null });\n  const runRef = React.useRef({\n    signature: \"\",\n    remaining: 0,\n    deadline: 0,\n    completed: false,\n    clockTime: 0,\n  });\n  const completeRef = React.useRef(onComplete);\n  const seconds = bounded(duration, 3600, 0, 863999999);\n  const target = timestamp(targetDate);\n  const fixedDate = timestamp(date);\n\n  React.useEffect(() => {\n    completeRef.current = onComplete;\n  }, [onComplete]);\n\n  React.useEffect(() => {\n    const run = runRef.current;\n    const signature =\n      mode === \"countdown\"\n        ? `${mode}:${seconds}:${target}`\n        : `${mode}:${fixedDate}`;\n    if (run.signature !== signature) {\n      run.signature = signature;\n      run.remaining =\n        mode === \"countdown\" && target !== undefined && Number.isFinite(target)\n          ? Math.max(0, target - Date.now())\n          : seconds * 1000;\n      run.completed = false;\n      run.clockTime = 0;\n    }\n    const relative = mode === \"countdown\" && target === undefined;\n    if (relative && running) run.deadline = performance.now() + run.remaining;\n    let timer: ReturnType<typeof setTimeout> | undefined;\n    let disposed = false;\n\n    const refresh = () => {\n      if (disposed) return;\n      clearTimeout(timer);\n      const wallTime = Date.now();\n      let time: number | null = null;\n      let remaining: number | null = null;\n      if (mode === \"clock\") {\n        if (fixedDate !== undefined)\n          time = Number.isFinite(fixedDate) ? fixedDate : null;\n        else {\n          if (running || !run.clockTime)\n            run.clockTime = Math.floor(wallTime / 1000) * 1000;\n          time = run.clockTime;\n        }\n      } else if (relative) {\n        if (running)\n          run.remaining = Math.max(0, run.deadline - performance.now());\n        remaining = Math.ceil(run.remaining / 1000);\n      } else if (Number.isFinite(target)) {\n        if (running) run.remaining = Math.max(0, target! - wallTime);\n        remaining = Math.ceil(run.remaining / 1000);\n      }\n      setSnapshot((previous) =>\n        previous.time === time && previous.remaining === remaining\n          ? previous\n          : { time, remaining },\n      );\n      if (mode === \"countdown\" && remaining === 0 && !run.completed) {\n        run.completed = true;\n        completeRef.current?.();\n      }\n      const needsTick =\n        running &&\n        (mode === \"clock\"\n          ? fixedDate === undefined\n          : remaining !== null && remaining > 0);\n      if (needsTick && !document.hidden) {\n        const delay = relative\n          ? (run.remaining % 1000 || 1000) + 10\n          : 1010 - (Date.now() % 1000);\n        timer = setTimeout(refresh, delay);\n      }\n    };\n    const visibility = () => {\n      if (document.hidden) clearTimeout(timer);\n      else refresh();\n    };\n    refresh();\n    document.addEventListener(\"visibilitychange\", visibility);\n    return () => {\n      disposed = true;\n      clearTimeout(timer);\n      document.removeEventListener(\"visibilitychange\", visibility);\n      if (relative && running)\n        run.remaining = Math.max(0, run.deadline - performance.now());\n    };\n  }, [mode, seconds, target, fixedDate, running]);\n\n  return snapshot;\n}\n\n",
      "type": "registry:ui",
      "target": "@ui/use-time-source.ts"
    }
  ]
}
