cropintel / components /CursorField.tsx
Jaithra Polavarapu
fix(hf): cursor activates under reduced-motion + broader pointer gate
8d30cba
Raw
History Blame Contribute Delete
4.23 kB
'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<HTMLDivElement>(null)
const ringRef = useRef<HTMLDivElement>(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 (
<>
<div
ref={dotRef}
aria-hidden
className="cursor-dot pointer-events-none fixed left-0 top-0 z-[100000] hidden h-2 w-2 rounded-full opacity-0 md:block"
/>
<div
ref={ringRef}
aria-hidden
className="cursor-ring pointer-events-none fixed left-0 top-0 z-[100000] hidden h-9 w-9 rounded-full border opacity-0 md:block"
/>
</>
)
}