mirror of
https://github.com/hanzoai/shadcn.git
synced 2026-10-30 18:22:59 +00:00
406 lines
9.6 KiB
TypeScript
406 lines
9.6 KiB
TypeScript
"use client"
|
|
|
|
import React, { useCallback, useEffect, useRef, useState } from "react"
|
|
|
|
import { cn } from "@/lib/utils"
|
|
|
|
export interface AnimatedCursorProps {
|
|
/**
|
|
* Whether the cursor is visible
|
|
*/
|
|
isVisible?: boolean
|
|
/**
|
|
* Cursor size in pixels
|
|
*/
|
|
size?: number
|
|
/**
|
|
* Main cursor color
|
|
*/
|
|
color?: string
|
|
/**
|
|
* Trail cursor color
|
|
*/
|
|
trailColor?: string
|
|
/**
|
|
* Animation duration in milliseconds
|
|
*/
|
|
animationDuration?: number
|
|
/**
|
|
* Whether to show trail effect
|
|
*/
|
|
showTrail?: boolean
|
|
/**
|
|
* Number of trail elements
|
|
*/
|
|
trailLength?: number
|
|
/**
|
|
* Blend mode for cursor
|
|
*/
|
|
blendMode?:
|
|
| "normal"
|
|
| "multiply"
|
|
| "screen"
|
|
| "overlay"
|
|
| "darken"
|
|
| "lighten"
|
|
| "color-dodge"
|
|
| "color-burn"
|
|
| "hard-light"
|
|
| "soft-light"
|
|
| "difference"
|
|
| "exclusion"
|
|
/**
|
|
* Scale factor on hover
|
|
*/
|
|
hoverScale?: number
|
|
/**
|
|
* Whether to hide on touch devices
|
|
*/
|
|
hideOnTouch?: boolean
|
|
/**
|
|
* Custom CSS class
|
|
*/
|
|
className?: string
|
|
/**
|
|
* Click animation duration
|
|
*/
|
|
clickAnimationDuration?: number
|
|
/**
|
|
* Z-index for cursor
|
|
*/
|
|
zIndex?: number
|
|
}
|
|
|
|
export interface CursorPosition {
|
|
x: number
|
|
y: number
|
|
}
|
|
|
|
export interface CursorState {
|
|
position: CursorPosition
|
|
isHovering: boolean
|
|
isClicking: boolean
|
|
cursorType: "default" | "pointer" | "text" | "grab" | "grabbing"
|
|
}
|
|
|
|
const INTERACTIVE_SELECTORS = [
|
|
"a",
|
|
"button",
|
|
"input",
|
|
"textarea",
|
|
"select",
|
|
'[role="button"]',
|
|
'[role="link"]',
|
|
'[tabindex]:not([tabindex="-1"])',
|
|
".cursor-pointer",
|
|
'[data-cursor="pointer"]',
|
|
].join(", ")
|
|
|
|
const TEXT_SELECTORS = [
|
|
'input[type="text"]',
|
|
'input[type="email"]',
|
|
'input[type="password"]',
|
|
'input[type="search"]',
|
|
"textarea",
|
|
'[contenteditable="true"]',
|
|
'[data-cursor="text"]',
|
|
].join(", ")
|
|
|
|
const GRAB_SELECTORS = ['[data-cursor="grab"]', ".cursor-grab"].join(", ")
|
|
|
|
const GRABBING_SELECTORS = [
|
|
'[data-cursor="grabbing"]',
|
|
".cursor-grabbing",
|
|
].join(", ")
|
|
|
|
/**
|
|
* Detects if the device supports touch
|
|
*/
|
|
function isTouchDevice(): boolean {
|
|
if (typeof window === "undefined") return false
|
|
|
|
return (
|
|
"ontouchstart" in window ||
|
|
navigator.maxTouchPoints > 0 ||
|
|
// @ts-ignore
|
|
navigator.msMaxTouchPoints > 0
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Custom hook for managing cursor state
|
|
*/
|
|
function useCursorState() {
|
|
const [cursorState, setCursorState] = useState<CursorState>({
|
|
position: { x: 0, y: 0 },
|
|
isHovering: false,
|
|
isClicking: false,
|
|
cursorType: "default",
|
|
})
|
|
|
|
const updatePosition = useCallback((x: number, y: number) => {
|
|
setCursorState((prev) => ({
|
|
...prev,
|
|
position: { x, y },
|
|
}))
|
|
}, [])
|
|
|
|
const updateHoverState = useCallback(
|
|
(
|
|
isHovering: boolean,
|
|
cursorType: CursorState["cursorType"] = "default"
|
|
) => {
|
|
setCursorState((prev) => ({
|
|
...prev,
|
|
isHovering,
|
|
cursorType,
|
|
}))
|
|
},
|
|
[]
|
|
)
|
|
|
|
const updateClickState = useCallback((isClicking: boolean) => {
|
|
setCursorState((prev) => ({
|
|
...prev,
|
|
isClicking,
|
|
}))
|
|
}, [])
|
|
|
|
return {
|
|
cursorState,
|
|
updatePosition,
|
|
updateHoverState,
|
|
updateClickState,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Custom hook for managing cursor trail
|
|
*/
|
|
function useCursorTrail(trailLength: number, position: CursorPosition) {
|
|
const [trail, setTrail] = useState<CursorPosition[]>([])
|
|
const trailRef = useRef<CursorPosition[]>([])
|
|
|
|
useEffect(() => {
|
|
trailRef.current.push(position)
|
|
|
|
if (trailRef.current.length > trailLength) {
|
|
trailRef.current.shift()
|
|
}
|
|
|
|
setTrail([...trailRef.current])
|
|
}, [position, trailLength])
|
|
|
|
return trail
|
|
}
|
|
|
|
/**
|
|
* AnimatedCursor component providing customizable animated cursor with trail effects
|
|
*/
|
|
export function AnimatedCursor({
|
|
isVisible = true,
|
|
size = 20,
|
|
color = "#000000",
|
|
trailColor = "#00000050",
|
|
animationDuration = 200,
|
|
showTrail = true,
|
|
trailLength = 8,
|
|
blendMode = "normal",
|
|
hoverScale = 1.5,
|
|
hideOnTouch = true,
|
|
className,
|
|
clickAnimationDuration = 100,
|
|
zIndex = 9999,
|
|
}: AnimatedCursorProps) {
|
|
const { cursorState, updatePosition, updateHoverState, updateClickState } =
|
|
useCursorState()
|
|
|
|
const trail = useCursorTrail(trailLength, cursorState.position)
|
|
const [isTouch, setIsTouch] = useState(false)
|
|
const [mounted, setMounted] = useState(false)
|
|
|
|
// Check for touch device on mount
|
|
useEffect(() => {
|
|
setIsTouch(isTouchDevice())
|
|
setMounted(true)
|
|
}, [])
|
|
|
|
// Mouse move handler
|
|
useEffect(() => {
|
|
if (!mounted || (hideOnTouch && isTouch)) return
|
|
|
|
const handleMouseMove = (e: MouseEvent) => {
|
|
try {
|
|
requestAnimationFrame(() => {
|
|
updatePosition(e.clientX, e.clientY)
|
|
})
|
|
} catch (error) {
|
|
console.warn("AnimatedCursor: Error updating position", error)
|
|
}
|
|
}
|
|
|
|
const handleMouseOver = (e: MouseEvent) => {
|
|
try {
|
|
const target = e.target as HTMLElement
|
|
|
|
if (target?.closest?.(INTERACTIVE_SELECTORS)) {
|
|
updateHoverState(true, "pointer")
|
|
} else if (target?.closest?.(TEXT_SELECTORS)) {
|
|
updateHoverState(true, "text")
|
|
} else if (target?.closest?.(GRAB_SELECTORS)) {
|
|
updateHoverState(true, "grab")
|
|
} else if (target?.closest?.(GRABBING_SELECTORS)) {
|
|
updateHoverState(true, "grabbing")
|
|
} else {
|
|
updateHoverState(false, "default")
|
|
}
|
|
} catch (error) {
|
|
console.warn("AnimatedCursor: Error handling mouse over", error)
|
|
updateHoverState(false, "default")
|
|
}
|
|
}
|
|
|
|
const handleMouseDown = () => {
|
|
updateClickState(true)
|
|
}
|
|
|
|
const handleMouseUp = () => {
|
|
updateClickState(false)
|
|
}
|
|
|
|
const handleMouseLeave = () => {
|
|
updateHoverState(false, "default")
|
|
}
|
|
|
|
document.addEventListener("mousemove", handleMouseMove)
|
|
document.addEventListener("mouseover", handleMouseOver)
|
|
document.addEventListener("mousedown", handleMouseDown)
|
|
document.addEventListener("mouseup", handleMouseUp)
|
|
document.addEventListener("mouseleave", handleMouseLeave)
|
|
|
|
return () => {
|
|
document.removeEventListener("mousemove", handleMouseMove)
|
|
document.removeEventListener("mouseover", handleMouseOver)
|
|
document.removeEventListener("mousedown", handleMouseDown)
|
|
document.removeEventListener("mouseup", handleMouseUp)
|
|
document.removeEventListener("mouseleave", handleMouseLeave)
|
|
}
|
|
}, [
|
|
mounted,
|
|
hideOnTouch,
|
|
isTouch,
|
|
updatePosition,
|
|
updateHoverState,
|
|
updateClickState,
|
|
])
|
|
|
|
// Hide default cursor
|
|
useEffect(() => {
|
|
if (!mounted || (hideOnTouch && isTouch)) return
|
|
|
|
try {
|
|
const originalCursor = document.body.style.cursor
|
|
document.body.style.cursor = "none"
|
|
|
|
return () => {
|
|
try {
|
|
document.body.style.cursor = originalCursor
|
|
} catch (error) {
|
|
console.warn("AnimatedCursor: Error restoring cursor style", error)
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.warn("AnimatedCursor: Error hiding cursor", error)
|
|
}
|
|
}, [mounted, hideOnTouch, isTouch])
|
|
|
|
// Don't render on server or touch devices (if hideOnTouch is true)
|
|
if (!mounted || !isVisible || (hideOnTouch && isTouch)) {
|
|
return null
|
|
}
|
|
|
|
const currentScale = cursorState.isHovering ? hoverScale : 1
|
|
const clickScale = cursorState.isClicking ? 0.8 : 1
|
|
const finalScale = currentScale * clickScale
|
|
|
|
const getCursorShape = () => {
|
|
switch (cursorState.cursorType) {
|
|
case "text":
|
|
return "h-6 w-0.5"
|
|
case "grab":
|
|
case "grabbing":
|
|
return "rounded-md"
|
|
default:
|
|
return "rounded-full"
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className={cn("pointer-events-none fixed inset-0", className)}>
|
|
{/* Trail */}
|
|
{showTrail &&
|
|
trail.map((point, index) => {
|
|
const opacity = ((index + 1) / trail.length) * 0.5
|
|
const scale = ((index + 1) / trail.length) * 0.8
|
|
|
|
return (
|
|
<div
|
|
key={index}
|
|
className="absolute"
|
|
style={{
|
|
left: point.x - size / 2,
|
|
top: point.y - size / 2,
|
|
width: size,
|
|
height: size,
|
|
backgroundColor: trailColor,
|
|
borderRadius: "50%",
|
|
opacity,
|
|
transform: `scale(${scale})`,
|
|
zIndex: zIndex - index - 1,
|
|
mixBlendMode: blendMode,
|
|
}}
|
|
/>
|
|
)
|
|
})}
|
|
|
|
{/* Main cursor */}
|
|
<div
|
|
className={cn(
|
|
"absolute transition-transform duration-100 ease-out",
|
|
getCursorShape()
|
|
)}
|
|
style={{
|
|
left: cursorState.position.x - size / 2,
|
|
top: cursorState.position.y - size / 2,
|
|
width: cursorState.cursorType === "text" ? 2 : size,
|
|
height: cursorState.cursorType === "text" ? 24 : size,
|
|
backgroundColor: color,
|
|
transform: `scale(${finalScale})`,
|
|
transitionDuration: `${animationDuration}ms`,
|
|
zIndex,
|
|
mixBlendMode: blendMode,
|
|
}}
|
|
/>
|
|
|
|
{/* Hover ring */}
|
|
{cursorState.isHovering && cursorState.cursorType !== "text" && (
|
|
<div
|
|
className="absolute rounded-full border-2 transition-all duration-200 ease-out"
|
|
style={{
|
|
left: cursorState.position.x - (size * hoverScale) / 2,
|
|
top: cursorState.position.y - (size * hoverScale) / 2,
|
|
width: size * hoverScale,
|
|
height: size * hoverScale,
|
|
borderColor: color,
|
|
opacity: 0.3,
|
|
zIndex: zIndex - 1,
|
|
mixBlendMode: blendMode,
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default AnimatedCursor
|