{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"mini-clock","type":"registry:component","title":"Mini clock","description":"A tiny analog clock that sweeps in its city's time and turns dark after sundown.","author":"Yash Bavadiya <https://xevrion.dev>","dependencies":[],"registryDependencies":["utils"],"files":[{"path":"components/mini-clock.tsx","type":"registry:component","content":"\"use client\";\n\nimport { useEffect, useMemo, useRef, useSyncExternalStore } from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { usePreviewPlay } from \"@/lib/preview-play\";\n\n// A minute-resolution store: the text and the day/night face only change on\n// the minute, so React renders once a minute while rAF writes move the hands.\nfunction subscribeMinute(onChange: () => void) {\n  let timer: ReturnType<typeof setTimeout>;\n  const schedule = () => {\n    // 20ms past the boundary so the snapshot is already the new minute.\n    timer = setTimeout(tick, 60_000 - (Date.now() % 60_000) + 20);\n  };\n  const tick = () => {\n    onChange();\n    schedule();\n  };\n  schedule();\n  return () => clearTimeout(timer);\n}\nconst minuteSnapshot = () => Math.floor(Date.now() / 60_000);\n// The server can't know the time, so it renders a neutral, handless face and\n// \"--:--\"; the client fills both in after hydration.\nconst serverSnapshot = () => null;\n\n// Milliseconds to add to UTC to get the wall clock in `timeZone`. Read once a\n// minute rather than per frame: formatToParts is far too slow for rAF.\nfunction zoneOffset(timeZone: string | undefined, at: number) {\n  const parts = new Intl.DateTimeFormat(\"en-US\", {\n    timeZone,\n    hourCycle: \"h23\",\n    year: \"numeric\",\n    month: \"numeric\",\n    day: \"numeric\",\n    hour: \"numeric\",\n    minute: \"numeric\",\n    second: \"numeric\",\n  }).formatToParts(at);\n  const get = (type: string) => Number(parts.find((p) => p.type === type)?.value);\n  const wall = Date.UTC(get(\"year\"), get(\"month\") - 1, get(\"day\"), get(\"hour\"), get(\"minute\"), get(\"second\"));\n  return wall - (at - (at % 1000));\n}\n\n// Twelve hour ticks, quarters longer. Rounded so server and client print\n// identical coordinates.\nconst round = (n: number) => Math.round(n * 100) / 100;\nconst TICKS = Array.from({ length: 12 }, (_, i) => {\n  const a = (i / 12) * Math.PI * 2;\n  const major = i % 3 === 0;\n  const inner = major ? 13 : 14.5;\n  return {\n    major,\n    x1: round(20 + Math.sin(a) * inner),\n    y1: round(20 - Math.cos(a) * inner),\n    x2: round(20 + Math.sin(a) * 16.5),\n    y2: round(20 - Math.cos(a) * 16.5),\n  };\n});\n\nconst HAND_ORIGIN = { transformOrigin: \"20px 20px\" } as const;\n\nexport function MiniClock({\n  place,\n  timeZone,\n  size = 36,\n  paused = false,\n  className,\n}: {\n  place: string;\n  /** IANA zone like \"Europe/Lisbon\". Defaults to the reader's own zone. */\n  timeZone?: string;\n  size?: number;\n  /** Stops the sweep: the hour and minute stay right, the second hand\n   * rests. For pages showing many clocks at once. */\n  paused?: boolean;\n  className?: string;\n}) {\n  const minute = useSyncExternalStore(subscribeMinute, minuteSnapshot, serverSnapshot);\n  const face = useRef<SVGSVGElement>(null);\n  const hourHand = useRef<SVGLineElement>(null);\n  const minuteHand = useRef<SVGLineElement>(null);\n  const secondHand = useRef<SVGGElement>(null);\n  // Where the second hand was last drawn, so a paused clock can hold it and\n  // a waking one can sweep on from it.\n  const secAngle = useRef<number | null>(null);\n  // Paused clocks redraw on the minute; live ones never need to.\n  const pausedMinute = paused ? minute : null;\n\n  const info = useMemo(() => {\n    if (minute === null) return null;\n    const at = minute * 60_000;\n    // Fixed locale so the text reads the same for every visitor.\n    const fmt = (o: Intl.DateTimeFormatOptions) =>\n      new Intl.DateTimeFormat(\"en-GB\", { timeZone, ...o }).format(at);\n    const hour = Number(fmt({ hour: \"numeric\", hourCycle: \"h23\" }));\n    return {\n      time: fmt({ hour: \"2-digit\", minute: \"2-digit\", hourCycle: \"h23\" }),\n      date: fmt({ weekday: \"short\", day: \"numeric\", month: \"short\" }),\n      iso: new Date(at).toISOString(),\n      night: hour < 6 || hour >= 19,\n    };\n  }, [minute, timeZone]);\n\n  const ready = info !== null;\n\n  useEffect(() => {\n    if (!ready) return;\n    const svg = face.current;\n    if (!svg) return;\n    const reduce = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n    // Waking from a pause, the second hand catches up clockwise over\n    // ~450ms instead of teleporting to the current second.\n    let catchUp =\n      !paused && !reduce.matches && secAngle.current !== null\n        ? { from: secAngle.current, at: performance.now() }\n        : null;\n\n    let offset = zoneOffset(timeZone, Date.now());\n    let offsetAt = Date.now();\n    let raf = 0;\n    let timer: ReturnType<typeof setTimeout> | undefined;\n    let visible = false;\n\n    const draw = () => {\n      const now = Date.now();\n      if (now - offsetAt > 60_000) {\n        // Picks up daylight saving changes without a reload.\n        offset = zoneOffset(timeZone, now);\n        offsetAt = now;\n      }\n      const t = new Date(now + offset);\n      // Reduced motion ticks the seconds instead of sweeping them.\n      const ms = reduce.matches ? 0 : t.getUTCMilliseconds();\n      const s = t.getUTCSeconds() + ms / 1000;\n      const m = t.getUTCMinutes() + s / 60;\n      const h = (t.getUTCHours() % 12) + m / 60;\n      let sec = s * 6;\n      if (paused && secAngle.current !== null) sec = secAngle.current;\n      else if (catchUp) {\n        const k = Math.min((performance.now() - catchUp.at) / 450, 1);\n        const ahead = (((sec - catchUp.from) % 360) + 360) % 360;\n        sec = catchUp.from + ahead * (1 - (1 - k) ** 3);\n        if (k === 1) catchUp = null;\n      }\n      secAngle.current = sec % 360;\n      secondHand.current?.style.setProperty(\"transform\", `rotate(${sec}deg)`);\n      minuteHand.current?.style.setProperty(\"transform\", `rotate(${m * 6}deg)`);\n      hourHand.current?.style.setProperty(\"transform\", `rotate(${h * 30}deg)`);\n    };\n\n    const loop = () => {\n      draw();\n      if (!visible || document.hidden) return;\n      if (reduce.matches) timer = setTimeout(loop, 1000 - (Date.now() % 1000));\n      else raf = requestAnimationFrame(loop);\n    };\n    const stop = () => {\n      cancelAnimationFrame(raf);\n      clearTimeout(timer);\n    };\n    const restart = () => {\n      stop();\n      loop();\n    };\n\n    // Places the hands before they fade in, so they never swing from 12.\n    draw();\n    // A paused clock costs nothing between minutes: no loop, no observers.\n    if (paused) return;\n\n    // The loop sleeps whenever the face is off screen or the tab is hidden.\n    const io = new IntersectionObserver(([entry]) => {\n      visible = entry.isIntersecting;\n      restart();\n    });\n    io.observe(svg);\n    document.addEventListener(\"visibilitychange\", restart);\n    reduce.addEventListener(\"change\", restart);\n    return () => {\n      stop();\n      io.disconnect();\n      document.removeEventListener(\"visibilitychange\", restart);\n      reduce.removeEventListener(\"change\", restart);\n    };\n  }, [ready, timeZone, paused, pausedMinute]);\n\n  const night = info?.night ?? false;\n\n  return (\n    <span\n      // Focusable so keyboard users can reveal the date, same as hovering.\n      tabIndex={0}\n      role=\"img\"\n      aria-label={info ? `${place}, ${info.time} local time, ${info.date}` : place}\n      className={cn(\n        \"group/clock relative inline-flex items-center gap-2.5 rounded-full py-0.5 pr-2 pl-0.5 align-middle text-sm outline-hidden focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-foreground\",\n        className,\n      )}\n    >\n      <svg\n        ref={face}\n        viewBox=\"0 0 40 40\"\n        width={size}\n        height={size}\n        aria-hidden\n        className={cn(\n          \"shrink-0 rounded-full\",\n          // The face inverts after dark. 400ms is slow for UI, but it runs\n          // twice a day and a soft dusk reads better than a snap.\n          \"transition-[color,background-color,box-shadow] duration-400 ease-[cubic-bezier(0.77,0,0.175,1)]\",\n          night ? \"bg-foreground text-background\" : \"bg-muted text-foreground shadow-raised\",\n        )}\n      >\n        {TICKS.map(({ major, ...line }, i) => (\n          <line\n            key={i}\n            {...line}\n            stroke=\"currentColor\"\n            strokeWidth={major ? 1.6 : 1}\n            strokeLinecap=\"round\"\n            opacity={major ? 0.5 : 0.22}\n          />\n        ))}\n        {/* Hands fade in once the client knows the time; the server face is\n            deliberately empty so nothing points at a wrong hour. */}\n        <g\n          className={cn(\n            \"transition-opacity duration-300 ease-out\",\n            ready ? \"opacity-100\" : \"opacity-0\",\n          )}\n        >\n          <line\n            ref={hourHand}\n            x1=\"20\"\n            y1=\"21.5\"\n            x2=\"20\"\n            y2=\"11.5\"\n            stroke=\"currentColor\"\n            strokeWidth={2.6}\n            strokeLinecap=\"round\"\n            style={HAND_ORIGIN}\n          />\n          <line\n            ref={minuteHand}\n            x1=\"20\"\n            y1=\"22\"\n            x2=\"20\"\n            y2=\"6.5\"\n            stroke=\"currentColor\"\n            strokeWidth={1.7}\n            strokeLinecap=\"round\"\n            style={HAND_ORIGIN}\n          />\n          {/* The only accent: a red-pen second hand, same on both faces. */}\n          <g ref={secondHand} className=\"text-marker\" style={HAND_ORIGIN}>\n            <line x1=\"20\" y1=\"24.5\" x2=\"20\" y2=\"5\" stroke=\"currentColor\" strokeWidth={0.9} strokeLinecap=\"round\" />\n            <circle cx=\"20\" cy=\"20\" r=\"1.7\" fill=\"currentColor\" />\n          </g>\n        </g>\n      </svg>\n\n      <span aria-hidden className=\"relative flex items-baseline whitespace-nowrap\">\n        <span className=\"font-medium text-foreground\">{place},</span>\n        <time dateTime={info?.iso} className=\"ml-1 text-muted-foreground tabular-nums\">\n          {info?.time ?? \"--:--\"}\n        </time>\n        {/* The date unfolds to the right on hover or focus. Out of flow, so\n            the line it sits in never reflows or recentres around it; a\n            left-to-right clip reads as the text being written out. */}\n        <span\n          className={cn(\n            \"pointer-events-none absolute top-0 left-full pl-2 text-muted-foreground\",\n            // Leaves faster and softer (150ms) than it arrives (220ms).\n            \"opacity-0 blur-[4px] [clip-path:inset(0_100%_0_0)] transition-[opacity,filter,clip-path] duration-150 ease-out\",\n            \"group-hover/clock:opacity-100 group-hover/clock:filter-none group-hover/clock:[clip-path:inset(0_0_0_0)] group-hover/clock:duration-[220ms] group-hover/clock:ease-[cubic-bezier(0.23,1,0.32,1)]\",\n            \"group-focus-visible/clock:opacity-100 group-focus-visible/clock:filter-none group-focus-visible/clock:[clip-path:inset(0_0_0_0)]\",\n            \"motion-reduce:[clip-path:inset(0_0_0_0)]\",\n          )}\n        >\n          {info?.date ?? \"\"}\n        </span>\n      </span>\n    </span>\n  );\n}\n\nexport default function MiniClockDemo() {\n  // In an index card the hands only sweep while the card is hovered; at\n  // rest they hold, still showing the right time.\n  const paused = usePreviewPlay() === false;\n  return (\n    // Two studios far enough apart that one is usually dark.\n    <div className=\"flex flex-col items-start gap-2\">\n      <MiniClock place=\"Lisbon\" timeZone=\"Europe/Lisbon\" paused={paused} />\n      <MiniClock place=\"Tokyo\" timeZone=\"Asia/Tokyo\" paused={paused} />\n    </div>\n  );\n}\n"},{"path":"lib/preview-play.ts","type":"registry:lib","target":"lib/preview-play.ts","content":"\"use client\";\n\nimport { createContext, useContext } from \"react\";\n\n// Lets a demo know whether it's showing in an index card, and whether that\n// card is hovered or focused. Demos use it to act themselves out on hover:\n// a star button stars itself, a checkbox scribbles, and so on.\n//\n// null: not in a preview (the component's own page, or real use).\n// false: in a preview, at rest.\n// true: in a preview, being hovered or focused: play the show.\nexport const PreviewPlayContext = createContext<boolean | null>(null);\n\nexport function usePreviewPlay() {\n  return useContext(PreviewPlayContext);\n}\n"}],"cssVars":{"light":{"marker":"#d93d31","shadow-raised":"0 0 0 1px oklch(0 0 0 / 0.06), 0 1px 2px oklch(0 0 0 / 0.06), 0 6px 16px -6px oklch(0 0 0 / 0.12)"},"dark":{"marker":"#ff6b5f","shadow-raised":"0 0 0 1px oklch(1 0 0 / 0.08), 0 1px 2px oklch(0 0 0 / 0.4), 0 6px 16px -6px oklch(0 0 0 / 0.6)"},"theme":{"color-marker":"var(--marker)","shadow-raised":"var(--shadow-raised)"}},"categories":["data"],"docs":"From ui lab: https://lab.xevrion.dev/lab/mini-clock"}