mirror of
https://github.com/Since-3/since3-website.git
synced 2026-10-12 21:41:16 +00:00
114 lines
3.5 KiB
TypeScript
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>
|
|
)
|
|
}
|