{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"clock-field","type":"registry:component","title":"Clock field","description":"A field of clocks whose hands turn as one, melting between a wave, a vortex, rings and a funnel; they look at your pointer, and a click sends a ripple through them.","author":"Yash Bavadiya <https://xevrion.dev>","dependencies":[],"registryDependencies":["utils"],"files":[{"path":"components/clock-field.tsx","type":"registry:component","content":"\"use client\";\n\nimport { useEffect, useRef } from \"react\";\nimport { usePreviewPlay } from \"@/lib/preview-play\";\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\nimport { cn } from \"@/lib/utils\";\n\n// After A. L. Crego's \"Time\" (https://x.com/alcrego_): a field of clock\n// faces whose hands turn together, so the field reads as one moving thing\n// rather than 256 clocks. Every pattern is smooth, each hand only a small\n// step from its neighbours, and the field drifts from one to the next: a\n// diagonal wave, a vortex, rings, a funnel. It also notices you: the hands\n// near the pointer turn to look at it, and a click sends a ring out that\n// spins every hand it passes one full turn.\n\nconst TAU = Math.PI * 2;\n// Faces along each side.\nconst COUNT = 16;\n// One lap of the wave every nine seconds: calm enough to watch.\nconst SPEED = TAU / 9;\n// Each pattern holds for HOLD seconds, then melts into the next over MELT.\nconst HOLD = 5;\nconst MELT = 2.6;\n\n// Where a face's hand points at rest, given its place in the field (x and\n// y from -1 to 1, centre 0) and its row and column. All of them turn\n// together over time, and each is gentle enough that neighbouring hands\n// never differ by more than about a twelfth of a turn, which is what makes\n// the field read as one surface instead of noise.\nconst PATTERNS: ((x: number, y: number, col: number, row: number) => number)[] = [\n  // A diagonal wave, the original.\n  (_x, _y, col, row) => ((col + row) * TAU) / 32,\n  // A vortex: one arm wound gently round the centre.\n  (x, y) => Math.atan2(-x, y) + Math.PI / 2 + Math.hypot(x, y) * 1.4,\n  // Rings spreading out from the middle.\n  (x, y) => Math.hypot(x, y) * 3.2,\n  // A funnel: every hand points to the centre.\n  (x, y) => Math.atan2(-x, y),\n];\n// How far the pointer's pull reaches, as a share of the field's width.\nconst REACH = 0.3;\n// How quickly a hand catches up with where it wants to point, per second.\nconst FOLLOW = 9;\n// A click's ring: how fast it travels and how wide its band of spinning\n// hands is, both as shares of the field's width.\nconst RING_SPEED = 0.85;\nconst RING_BAND = 0.22;\n\ntype Ring = { x: number; y: number; born: number };\n\n// A hand at angle 0 points to twelve o'clock, turning clockwise.\nconst shortest = (from: number, to: number) => {\n  const d = (to - from) % TAU;\n  return d > Math.PI ? d - TAU : d < -Math.PI ? d + TAU : d;\n};\nconst ease = (t: number) => (t <= 0 ? 0 : t >= 1 ? 1 : t * t * (3 - 2 * t));\n\nexport function ClockField({ className }: { className?: string }) {\n  const reduceMotion = useReducedMotion();\n  const play = usePreviewPlay();\n  const canvasRef = useRef<HTMLCanvasElement>(null);\n  // Everything the loop reads lives in a ref, so moving the pointer never\n  // re-renders React.\n  const state = useRef({\n    size: 0,\n    angles: new Float32Array(COUNT * COUNT),\n    spun: new Float32Array(COUNT * COUNT),\n    seeded: false,\n    pointer: { x: 0, y: 0, on: false, pull: 0, releaseAt: 0 },\n    rings: [] as Ring[],\n  });\n\n  useEffect(() => {\n    const canvas = canvasRef.current;\n    if (!canvas) return;\n    const ctx = canvas.getContext(\"2d\");\n    if (!ctx) return;\n    const s = state.current;\n    let frame = 0;\n    let last = 0;\n    let visible = false;\n    const start = performance.now();\n    // Index cards idle as a still picture; the page and a hovered card run.\n    const animate = !reduceMotion && play !== false;\n\n    let dpr = 1;\n    const resize = () => {\n      const size = canvas.clientWidth;\n      // The screen's own density, page zoom included, so hands stay crisp\n      // however close you look.\n      dpr = Math.min(window.devicePixelRatio || 1, 4);\n      s.size = size;\n      canvas.width = Math.round(size * dpr);\n      canvas.height = Math.round(size * dpr);\n      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n    };\n\n    const step = (now: number, still: boolean) => {\n      const dt = last ? Math.min(0.05, (now - last) / 1000) : 0;\n      last = now;\n      const t = (now - start) / 1000;\n      const { size, angles, spun, pointer, rings } = s;\n      if (!size) return;\n      const cell = size / COUNT;\n\n      // The card's show: a pointer that circles the middle, and a ring\n      // every few seconds.\n      if (play) {\n        const orbit = t * (TAU / 4.5);\n        pointer.x = size / 2 + Math.cos(orbit) * size * 0.26;\n        pointer.y = size / 2 + Math.sin(orbit) * size * 0.26;\n        pointer.on = true;\n        const due = Math.floor(t / 3);\n        if (!rings.some((r) => r.born === due) && t - due * 3 < 0.05) {\n          rings.push({ x: size / 2, y: size / 2, born: due });\n        }\n      }\n      if (pointer.releaseAt && now > pointer.releaseAt) {\n        pointer.on = false;\n        pointer.releaseAt = 0;\n      }\n      // The pull fades in and out rather than switching.\n      pointer.pull += ((pointer.on ? 1 : 0) - pointer.pull) * (1 - Math.exp(-6 * dt));\n\n      const reach = REACH * size;\n      const ringSpeed = RING_SPEED * size;\n      const band = RING_BAND * size;\n      const farthest = Math.hypot(size, size);\n      // Rings that have crossed the whole field have spun every hand a full\n      // turn, which looks the same as none, so they can go.\n      const birth = (r: Ring) => (play ? r.born * 3 : r.born);\n      for (let k = rings.length - 1; k >= 0; k--) {\n        if ((t - birth(rings[k])) * ringSpeed > farthest + band) {\n          rings.splice(k, 1);\n        }\n      }\n\n      // Which pattern the field is in, and how far it has melted into the\n      // next one.\n      const clock = still ? 0 : t;\n      const cycle = HOLD + MELT;\n      const p = Math.floor(clock / cycle) % PATTERNS.length;\n      const from = PATTERNS[p];\n      const to = PATTERNS[(p + 1) % PATTERNS.length];\n      const mix = ease(((clock % cycle) - HOLD) / MELT);\n\n      for (let row = 0; row < COUNT; row++) {\n        for (let col = 0; col < COUNT; col++) {\n          const i = row * COUNT + col;\n          const cx = (col + 0.5) * cell;\n          const cy = (row + 0.5) * cell;\n          const nx = (cx / size) * 2 - 1;\n          const ny = (cy / size) * 2 - 1;\n          const a = from(nx, ny, col, row);\n          let target =\n            (mix > 0 ? a + shortest(a, to(nx, ny, col, row)) * mix : a) +\n            clock * SPEED;\n          if (pointer.pull > 0.001) {\n            const dx = pointer.x - cx;\n            const dy = pointer.y - cy;\n            const d = Math.hypot(dx, dy);\n            const weight = pointer.pull * Math.exp(-((d / reach) ** 2));\n            const look = Math.atan2(dx, -dy);\n            target += shortest(target, look) * weight;\n          }\n          if (!s.seeded || still) {\n            angles[i] = target;\n          } else {\n            angles[i] += shortest(angles[i], target) * (1 - Math.exp(-FOLLOW * dt));\n          }\n          // Each ring adds up to one full turn as its band passes; only the\n          // change since last frame is applied, so the spin is continuous.\n          let spin = 0;\n          for (const r of rings) {\n            const front = (t - birth(r)) * ringSpeed;\n            const d = Math.hypot(cx - r.x, cy - r.y);\n            spin += TAU * ease((front - d) / band);\n          }\n          angles[i] += spin - spun[i];\n          spun[i] = spin;\n        }\n      }\n      s.seeded = true;\n\n      const style = getComputedStyle(canvas);\n      const radius = cell / 2;\n      ctx.clearRect(0, 0, size, size);\n      ctx.fillStyle = style.color;\n      ctx.beginPath();\n      for (let row = 0; row < COUNT; row++) {\n        for (let col = 0; col < COUNT; col++) {\n          const cx = (col + 0.5) * cell;\n          const cy = (row + 0.5) * cell;\n          ctx.moveTo(cx + radius, cy);\n          ctx.arc(cx, cy, radius, 0, TAU);\n        }\n      }\n      ctx.fill();\n      ctx.strokeStyle = style.textDecorationColor;\n      // Fine as a watch hand, but never under one real pixel.\n      ctx.lineWidth = Math.max(1 / dpr, cell * 0.034);\n      ctx.lineCap = \"round\";\n      ctx.beginPath();\n      const hand = radius * 0.84;\n      for (let row = 0; row < COUNT; row++) {\n        for (let col = 0; col < COUNT; col++) {\n          const a = angles[row * COUNT + col];\n          const cx = (col + 0.5) * cell;\n          const cy = (row + 0.5) * cell;\n          ctx.moveTo(cx, cy);\n          ctx.lineTo(cx + Math.sin(a) * hand, cy - Math.cos(a) * hand);\n        }\n      }\n      ctx.stroke();\n    };\n\n    const loop = (now: number) => {\n      step(now, false);\n      frame = visible ? requestAnimationFrame(loop) : 0;\n    };\n    const paint = () => {\n      if (!frame) step(performance.now(), !animate);\n    };\n\n    resize();\n    paint();\n\n    // Sleeps off screen.\n    const io = new IntersectionObserver(([entry]) => {\n      visible = entry.isIntersecting;\n      if (visible && animate && !frame) {\n        last = 0;\n        frame = requestAnimationFrame(loop);\n      }\n    });\n    if (animate) io.observe(canvas);\n    const ro = new ResizeObserver(() => {\n      resize();\n      paint();\n    });\n    ro.observe(canvas);\n    // Faces and hands follow the theme.\n    const mo = new MutationObserver(paint);\n    mo.observe(document.documentElement, { attributes: true, attributeFilter: [\"data-theme\", \"class\"] });\n    const media = matchMedia(\"(prefers-color-scheme: dark)\");\n    media.addEventListener(\"change\", paint);\n    // Zooming the page or moving to another screen changes the density;\n    // the query only fires once, so it's re-armed each time.\n    let density: MediaQueryList | null = null;\n    const onDensity = () => {\n      resize();\n      paint();\n      watchDensity();\n    };\n    const watchDensity = () => {\n      density?.removeEventListener(\"change\", onDensity);\n      density = matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`);\n      density.addEventListener(\"change\", onDensity);\n    };\n    watchDensity();\n\n    // Reduced motion: no loop, but the hands still turn to the pointer, at\n    // once.\n    const still = () => {\n      if (!animate) step(performance.now(), true);\n    };\n\n    const toLocal = (e: PointerEvent) => {\n      const rect = canvas.getBoundingClientRect();\n      // Layout size over drawn size, in case the field is shown scaled.\n      const k = s.size / rect.width;\n      return { x: (e.clientX - rect.left) * k, y: (e.clientY - rect.top) * k };\n    };\n    const onMove = (e: PointerEvent) => {\n      if (e.pointerType === \"touch\") return;\n      const p = toLocal(e);\n      s.pointer.x = p.x;\n      s.pointer.y = p.y;\n      s.pointer.on = true;\n      s.pointer.releaseAt = 0;\n      still();\n    };\n    const onLeave = (e: PointerEvent) => {\n      if (e.pointerType === \"touch\") return;\n      s.pointer.on = false;\n      if (!animate) {\n        s.pointer.pull = 0;\n        still();\n      }\n    };\n    const onDown = (e: PointerEvent) => {\n      const p = toLocal(e);\n      if (e.pointerType === \"touch\") {\n        // A finger can't hover, so a tap is where the hands look for a\n        // moment, as well as where the ring starts.\n        s.pointer.x = p.x;\n        s.pointer.y = p.y;\n        s.pointer.on = true;\n        s.pointer.releaseAt = performance.now() + 1600;\n      }\n      if (animate) s.rings.push({ ...p, born: (performance.now() - start) / 1000 });\n      still();\n    };\n    const onKey = (e: KeyboardEvent) => {\n      if (e.key !== \"Enter\" && e.key !== \" \") return;\n      e.preventDefault();\n      if (animate) {\n        s.rings.push({ x: s.size / 2, y: s.size / 2, born: (performance.now() - start) / 1000 });\n      }\n    };\n    if (play === null) {\n      canvas.addEventListener(\"pointermove\", onMove);\n      canvas.addEventListener(\"pointerleave\", onLeave);\n      canvas.addEventListener(\"pointerdown\", onDown);\n      canvas.addEventListener(\"keydown\", onKey);\n    }\n\n    return () => {\n      cancelAnimationFrame(frame);\n      io.disconnect();\n      ro.disconnect();\n      mo.disconnect();\n      media.removeEventListener(\"change\", paint);\n      density?.removeEventListener(\"change\", onDensity);\n      canvas.removeEventListener(\"pointermove\", onMove);\n      canvas.removeEventListener(\"pointerleave\", onLeave);\n      canvas.removeEventListener(\"pointerdown\", onDown);\n      canvas.removeEventListener(\"keydown\", onKey);\n      if (play !== null) {\n        s.rings = [];\n        s.pointer.on = false;\n        s.pointer.pull = 0;\n      }\n    };\n  }, [play, reduceMotion]);\n\n  return (\n    <canvas\n      ref={canvasRef}\n      role=\"img\"\n      tabIndex={0}\n      aria-label=\"A field of clock faces turning in a slow wave. The hands follow your pointer; click to send a ripple through them.\"\n      className={cn(\n        \"block aspect-square w-[420px] max-w-full touch-manipulation text-foreground decoration-background outline-hidden focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-4 focus-visible:outline-foreground\",\n        className,\n      )}\n    />\n  );\n}\n\nexport default function ClockFieldDemo() {\n  return <ClockField />;\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"},{"path":"hooks/use-reduced-motion.ts","type":"registry:hook","target":"hooks/use-reduced-motion.ts","content":"\"use client\";\n\nimport { useSyncExternalStore } from \"react\";\n\n// Motion's useReducedMotion reads the media query on the very first client\n// render, but the server can't know it, so every component that renders\n// differently under reduced motion broke hydration for those users. This\n// reports false during hydration, matching the server HTML, then the real\n// preference straight after, and follows it if it changes.\nconst query = \"(prefers-reduced-motion: reduce)\";\n\nfunction subscribe(onChange: () => void) {\n  const media = window.matchMedia(query);\n  media.addEventListener(\"change\", onChange);\n  return () => media.removeEventListener(\"change\", onChange);\n}\n\nexport function useReducedMotion() {\n  return useSyncExternalStore(\n    subscribe,\n    () => window.matchMedia(query).matches,\n    () => false,\n  );\n}\n"}],"categories":["playground"],"docs":"From ui lab: https://lab.xevrion.dev/lab/clock-field"}