'use client' /** * CursorField — a simple custom cursor: a pixel-accurate emerald dot plus a * larger ring that lags gently behind it (the trailing-ring effect), matching * the marketing site. Over links/buttons the ring grows and darkens; on click * it contracts. Self-disables on coarse pointers (touch) and when * prefers-reduced-motion is set, and is hidden below the md breakpoint. */ import { useEffect, useRef } from 'react' // emerald accent (primary-500 / leaf-deep), matching the site const LEAF = { r: 31, g: 168, b: 102 } const LEAF_DEEP = { r: 14, g: 122, b: 71 } export default function CursorField() { const dotRef = useRef(null) const ringRef = useRef(null) useEffect(() => { // Activate whenever a mouse-like pointer exists (pointer/any-pointer fine, // or hover capability). Reduced-motion must NOT remove the cursor; it only // drops the trailing lag (handled in the tick below). const finePointer = window.matchMedia('(pointer: fine)').matches || window.matchMedia('(any-pointer: fine)').matches || window.matchMedia('(hover: hover)').matches if (!finePointer) return const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches const lerp = reduced ? 1 : 0.18 const dot = dotRef.current const ring = ringRef.current if (!dot || !ring) return document.body.classList.add('cursor-field-active') const target = { x: window.innerWidth / 2, y: window.innerHeight / 2 } const ringPos = { x: target.x, y: target.y } let hasMoved = false let pressed = false let overInteractive = false const onMove = (e: PointerEvent) => { target.x = e.clientX target.y = e.clientY if (!hasMoved) { hasMoved = true ringPos.x = target.x ringPos.y = target.y dot.style.opacity = '1' ring.style.opacity = '1' } const el = e.target instanceof Element ? e.target : null overInteractive = !!el?.closest('a, button, input, textarea, select, [role="button"], label') } const onDown = () => (pressed = true) const onUp = () => (pressed = false) const onLeave = () => { dot.style.opacity = '0' ring.style.opacity = '0' } const onEnter = () => { if (hasMoved) { dot.style.opacity = '1' ring.style.opacity = '1' } } let raf = 0 const tick = () => { raf = requestAnimationFrame(tick) ringPos.x += (target.x - ringPos.x) * lerp ringPos.y += (target.y - ringPos.y) * lerp dot.style.transform = `translate(${target.x}px, ${target.y}px) translate(-50%, -50%)` const ringScale = (pressed ? 0.7 : 1) * (overInteractive ? 1.7 : 1) ring.style.transform = `translate(${ringPos.x}px, ${ringPos.y}px) translate(-50%, -50%) scale(${ringScale})` ring.style.borderColor = overInteractive ? `rgba(${LEAF_DEEP.r}, ${LEAF_DEEP.g}, ${LEAF_DEEP.b}, 0.9)` : `rgba(${LEAF.r}, ${LEAF.g}, ${LEAF.b}, 0.55)` } window.addEventListener('pointermove', onMove, { passive: true }) window.addEventListener('pointerdown', onDown, { passive: true }) window.addEventListener('pointerup', onUp, { passive: true }) document.addEventListener('pointerleave', onLeave) document.addEventListener('pointerenter', onEnter) raf = requestAnimationFrame(tick) return () => { cancelAnimationFrame(raf) window.removeEventListener('pointermove', onMove) window.removeEventListener('pointerdown', onDown) window.removeEventListener('pointerup', onUp) document.removeEventListener('pointerleave', onLeave) document.removeEventListener('pointerenter', onEnter) document.body.classList.remove('cursor-field-active') } }, []) return ( <>
) }