since3-website/modules/shared/ui/components/custom-cursor.tsx
2026-09-25 18:53:25 +02:00

114 lines
3.5 KiB
TypeScript

"use client"
import { useEffect, useRef, useState } from "react"
import { cn } from "@/lib/utils"
type CursorMode = "hidden" | "default" | "hover" | "label"
const INTERACTIVE = "a, button, summary, label, [role='tab'], [data-cursor]"
const FOLLOW = 0.22
/**
* A blend-mode dot that inverts whatever is below it. It grows over
* interactive elements and shows a label over elements with `data-cursor`.
*/
export function CustomCursor() {
const ref = useRef<HTMLDivElement>(null)
const [mode, setMode] = useState<CursorMode>("hidden")
const [label, setLabel] = useState("")
const [pressed, setPressed] = useState(false)
useEffect(() => {
const fine = window.matchMedia("(hover: hover) and (pointer: fine)").matches
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches
if (!fine || reduced) return
const root = document.documentElement
root.classList.add("has-cursor")
let x = -100
let y = -100
let targetX = -100
let targetY = -100
let frame = 0
const tick = () => {
x += (targetX - x) * FOLLOW
y += (targetY - y) * FOLLOW
if (ref.current) ref.current.style.transform = `translate3d(${x}px, ${y}px, 0)`
const settled = Math.abs(targetX - x) < 0.1 && Math.abs(targetY - y) < 0.1
frame = settled ? 0 : requestAnimationFrame(tick)
}
const onMove = (event: PointerEvent) => {
targetX = event.clientX
targetY = event.clientY
// First movement after load or after leaving the window
setMode((current) => (current === "hidden" ? "default" : current))
if (!frame) frame = requestAnimationFrame(tick)
}
const onOver = (event: PointerEvent) => {
const target = (event.target as Element | null)?.closest<HTMLElement>(INTERACTIVE)
if (target?.dataset.cursor) {
setLabel(target.dataset.cursor)
setMode("label")
} else {
setMode(target ? "hover" : "default")
}
}
const onLeaveWindow = () => setMode("hidden")
const onDown = () => setPressed(true)
const onUp = () => setPressed(false)
window.addEventListener("pointermove", onMove, { passive: true })
document.addEventListener("pointerover", onOver)
document.documentElement.addEventListener("pointerleave", onLeaveWindow)
window.addEventListener("pointerdown", onDown)
window.addEventListener("pointerup", onUp)
return () => {
root.classList.remove("has-cursor")
cancelAnimationFrame(frame)
window.removeEventListener("pointermove", onMove)
document.removeEventListener("pointerover", onOver)
document.documentElement.removeEventListener("pointerleave", onLeaveWindow)
window.removeEventListener("pointerdown", onDown)
window.removeEventListener("pointerup", onUp)
}
}, [])
const scale = {
hidden: 0,
default: 0.12,
hover: 0.5,
label: 1,
}[mode]
return (
<div
ref={ref}
aria-hidden="true"
className={cn(
"pointer-events-none fixed top-0 left-0 z-[var(--z-cursor)] will-change-transform",
mode !== "label" && "mix-blend-difference"
)}
>
<div
style={{ scale: pressed ? scale * 0.8 : scale }}
className="-mt-14 -ml-14 grid size-28 place-items-center rounded-full bg-white transition-[scale] duration-500 ease-out-expo"
>
<span
className={cn(
"font-mono text-label text-black transition-opacity duration-200",
mode === "label" ? "opacity-100" : "opacity-0"
)}
>
{label}
</span>
</div>
</div>
)
}