Cursor
Animated cursor for Vision UI
'use client'
import {
animate,
frame,
motion,
useMotionTemplate,
useMotionValue,
useSpring,
useTransform,
} from 'motion/react'
import { useEffect, useRef, useState } from 'react'
const CONSTANTS = {
TEXT_ELEMENT_TAGS: ['P', 'SPAN', 'H1', 'H2', 'H3', 'H4', 'TEXTAREA'],
HOVER_ELEMENT_TAGS: ['BUTTON', 'A', 'LABEL', 'SELECT'],
HOVERABLE_CLASSNAME: 'vision-pro-ui-hoverable', // No translate X or Y
CURSOR_SPRING_CONFIG: { stiffness: 90, damping: 2, mass: 0.01 },
DEFAULT_SPRING_CONFIG: { stiffness: 90, damping: 8, mass: 0.2 },
CLICK_SPRING_CONFIG: { duration: 0.12 },
DEFAULT_CURSOR_SIZE: 20,
CURSOR_BORDER_RADIUS: 10,
CURSOR_BLUR: 4,
DEFAULT_CURSOR_OPACITY: 0.5,
TEXT_CURSOR_OPACITY: 0.9,
ACTIVE_CURSOR_OPACITY: 0.3,
HOVER_EFFECT_X_MULTIPLIER: 1,
HOVER_EFFECT_Y_MULTIPLIER: 4,
SHINE_SIZE: 150,
SHINE_OPACITY: 0.65,
CLICK_ELEMENT_SCALE: 0.95,
CLICK_CURSOR_SCALE: 0.9,
}
const CursorInner = () => {
const cursorRef = useRef(null)
const isCursorLockedRef = useRef(false)
const [hoveredElement, setHoveredElement] = useState<HTMLElement | null>(null)
const cursorXSpring = useSpring(0, CONSTANTS.CURSOR_SPRING_CONFIG)
const cursorYSpring = useSpring(0, CONSTANTS.CURSOR_SPRING_CONFIG)
const cursorWidthSpring = useSpring(CONSTANTS.DEFAULT_CURSOR_SIZE, CONSTANTS.CURSOR_SPRING_CONFIG)
const cursorHeightSpring = useSpring(
CONSTANTS.DEFAULT_CURSOR_SIZE,
CONSTANTS.CURSOR_SPRING_CONFIG
)
const cursorOpacitySpring = useSpring(
CONSTANTS.DEFAULT_CURSOR_OPACITY,
CONSTANTS.CURSOR_SPRING_CONFIG
)
const cursorScaleSpring = useSpring(1, CONSTANTS.DEFAULT_SPRING_CONFIG)
const cursorBorderRadiusSpring = useSpring(CONSTANTS.CURSOR_BORDER_RADIUS, {
bounce: 0,
})
const cursorBlurSpring = useSpring(CONSTANTS.CURSOR_BLUR, CONSTANTS.DEFAULT_SPRING_CONFIG)
const translateX = useMotionValue(0)
const translateY = useMotionValue(0)
const shineX = useSpring(0, CONSTANTS.DEFAULT_SPRING_CONFIG)
const shineY = useSpring(0, CONSTANTS.DEFAULT_SPRING_CONFIG)
const cursorBackgroundOpacity = useSpring(1, { bounce: 0 })
const cursorBlurSpringString = useMotionTemplate`blur(${cursorBlurSpring}px)`
const cursorBackgroundColorString = useMotionTemplate`rgba(128, 128, 128, ${cursorBackgroundOpacity})`
const cursorLeft = useTransform<number, number>(
[cursorXSpring, cursorWidthSpring],
([x, width]) => x - width / 2
)
const cursorTop = useTransform<number, number>(
[cursorYSpring, cursorHeightSpring],
([y, height]) => y - height / 2
)
const handleElementMouseMove = (event: MouseEvent) => {
if (!hoveredElement) return
if (CONSTANTS.HOVER_ELEMENT_TAGS.includes(hoveredElement.tagName)) {
const rect = hoveredElement.getBoundingClientRect()
const halfHeight = rect.height / 2
const topOffset = (event.clientY - rect.top - halfHeight) / halfHeight
const halfWidth = rect.width / 2
const leftOffset = (event.clientX - rect.left - halfWidth) / halfWidth
translateX.set(leftOffset * CONSTANTS.HOVER_EFFECT_X_MULTIPLIER * 2)
translateY.set(topOffset * CONSTANTS.HOVER_EFFECT_Y_MULTIPLIER * 0.5)
shineX.set(event.clientX - rect.left - CONSTANTS.SHINE_SIZE / 2)
shineY.set(event.clientY - rect.top - CONSTANTS.SHINE_SIZE / 2)
animate(
hoveredElement,
{
x: leftOffset * CONSTANTS.HOVER_EFFECT_X_MULTIPLIER * 4,
y: topOffset * CONSTANTS.HOVER_EFFECT_Y_MULTIPLIER,
'--btn-bg-translate-x': `${-leftOffset * CONSTANTS.HOVER_EFFECT_X_MULTIPLIER * 2}px`,
'--btn-bg-translate-y': `${-topOffset * CONSTANTS.HOVER_EFFECT_Y_MULTIPLIER * 0.5}px`,
},
{ type: 'keyframes', duration: 0 }
)
} else if (
hoveredElement.className.length &&
hoveredElement.className
.split(' ')
.some((className) => className === CONSTANTS.HOVERABLE_CLASSNAME)
) {
const rect = hoveredElement.getBoundingClientRect()
shineX.set(event.clientX - rect.left - CONSTANTS.SHINE_SIZE / 2)
shineY.set(event.clientY - rect.top - CONSTANTS.SHINE_SIZE / 2)
}
}
const handleElementMouseLeave = () => {
if (!hoveredElement) return
cursorBorderRadiusSpring.set(CONSTANTS.CURSOR_BORDER_RADIUS)
cursorOpacitySpring.set(CONSTANTS.DEFAULT_CURSOR_OPACITY)
cursorBlurSpring.set(0)
cursorBackgroundOpacity.set(1)
if (CONSTANTS.HOVER_ELEMENT_TAGS.includes(hoveredElement.tagName)) {
cursorWidthSpring.set(CONSTANTS.DEFAULT_CURSOR_SIZE)
cursorHeightSpring.set(CONSTANTS.DEFAULT_CURSOR_SIZE)
translateX.set(0)
translateY.set(0)
}
}
const handleElementMouseDown = () => {
if (hoveredElement && CONSTANTS.HOVER_ELEMENT_TAGS.includes(hoveredElement.tagName)) {
animate(
hoveredElement,
{ scale: CONSTANTS.CLICK_ELEMENT_SCALE },
CONSTANTS.CLICK_SPRING_CONFIG
)
cursorScaleSpring.set(CONSTANTS.CLICK_CURSOR_SCALE)
}
}
const handleElementMouseUp = () => {
if (hoveredElement && CONSTANTS.HOVER_ELEMENT_TAGS.includes(hoveredElement.tagName)) {
animate(hoveredElement, { scale: 1 }, CONSTANTS.CLICK_SPRING_CONFIG)
cursorScaleSpring.set(1)
}
}
const handleMouseMove = (e: MouseEvent) => {
if (!isCursorLockedRef.current) {
cursorXSpring.set(e.clientX)
cursorYSpring.set(e.clientY)
}
const element = document.elementFromPoint(e.clientX, e.clientY) as HTMLElement
if (hoveredElement !== element) {
// disable if data-cursor-disabled is true
if (element.getAttribute('data-cursor-disabled')) return
setHoveredElement(element)
if (
CONSTANTS.HOVER_ELEMENT_TAGS.includes(element.tagName) ||
(element.className.length &&
element.className
.split(' ')
.some((className) => className === CONSTANTS.HOVERABLE_CLASSNAME))
) {
isCursorLockedRef.current = true
let rect: DOMRect
frame.read(() => {
rect = element.getBoundingClientRect()
cursorXSpring.set(rect.left + rect.width / 2)
cursorYSpring.set(rect.top + rect.height / 2)
})
frame.update(() => {
cursorWidthSpring.set((rect?.width ?? 4) - 4)
cursorHeightSpring.set((rect?.height ?? 4) - 4)
cursorBorderRadiusSpring.set(
parseInt(window.getComputedStyle(element).borderRadius, 10) - 2
)
cursorOpacitySpring.set(CONSTANTS.ACTIVE_CURSOR_OPACITY)
cursorBlurSpring.set(CONSTANTS.CURSOR_BLUR)
cursorBackgroundOpacity.set(0)
})
} else {
// Reset logic for non-hoverable elements
isCursorLockedRef.current = false
cursorWidthSpring.set(CONSTANTS.DEFAULT_CURSOR_SIZE)
cursorHeightSpring.set(CONSTANTS.DEFAULT_CURSOR_SIZE)
cursorBorderRadiusSpring.set(CONSTANTS.CURSOR_BORDER_RADIUS)
cursorOpacitySpring.set(CONSTANTS.DEFAULT_CURSOR_OPACITY)
cursorBlurSpring.set(0)
cursorBackgroundOpacity.set(1)
}
}
}
useEffect(() => {
document.addEventListener('mousemove', handleMouseMove)
return () => {
document.removeEventListener('mousemove', handleMouseMove)
}
}, [])
const cleanup = () => {
if (hoveredElement) {
hoveredElement.removeEventListener('mousemove', handleElementMouseMove)
hoveredElement.removeEventListener('mouseleave', handleElementMouseLeave)
hoveredElement.removeEventListener('mousedown', handleElementMouseDown)
hoveredElement.removeEventListener('mouseup', handleElementMouseUp)
animate(
hoveredElement,
{
x: 0,
y: 0,
'--btn-bg-translate-x': `${0}px`,
'--btn-bg-translate-y': `${0}px`,
},
{ type: 'spring', duration: 0.4 }
)
}
}
useEffect(() => {
if (hoveredElement) {
hoveredElement.addEventListener('mousemove', handleElementMouseMove)
hoveredElement.addEventListener('mouseleave', handleElementMouseLeave)
hoveredElement.addEventListener('mousedown', handleElementMouseDown)
hoveredElement.addEventListener('mouseup', handleElementMouseUp)
}
return cleanup
}, [hoveredElement])
return (
<motion.div
ref={cursorRef}
className="pointer-events-none fixed z-[999] overflow-hidden"
style={{
left: cursorLeft,
top: cursorTop,
width: cursorWidthSpring,
height: cursorHeightSpring,
opacity: cursorOpacitySpring,
backgroundColor: cursorBackgroundColorString,
borderRadius: cursorBorderRadiusSpring,
scale: cursorScaleSpring,
translateX: translateX,
translateY: translateY,
filter: cursorBlurSpringString,
}}
>
<motion.div
className="absolute"
style={{
width: CONSTANTS.SHINE_SIZE,
height: CONSTANTS.SHINE_SIZE,
borderRadius: '50%',
opacity: CONSTANTS.SHINE_OPACITY,
left: shineX,
top: shineY,
background:
'radial-gradient(circle, rgba(255,255,255,0.35) 0%, rgba(255,255,255,0) 75%), radial-gradient(101.08% 100% at 50% 100%, rgba(94, 94, 94, 0.14) 0%, rgba(94, 94, 94, 0.00) 73.85%), radial-gradient(100.02% 100% at 50% 100%, rgba(255, 255, 255, 0.3) 0%, rgba(255, 255, 255, 0.00) 55.59%), linear-gradient(0deg, rgba(94, 94, 94, 0.18) 0%, rgba(94, 94, 94, 0.18) 100%), rgba(255, 255, 255, 0.1)',
// @ts-expect-error mix-blend-mode is a valid type
mixBlendMode: 'color-dodge, normal, color-dodge, lighten',
}}
/>
</motion.div>
)
}
type CursorProps = {
/**
* Whether the cursor should be visible in all environments.
*
* This overrides the default behavior of checking if the device is a touch device.
* @default false
*/
visibleOnTouch?: boolean
}
const Cursor = ({ visibleOnTouch = false }: CursorProps) => {
const [isTouchDevice, setIsTouchDevice] = useState(false)
const checkIfTouchDevice = () => {
const isTouch = 'ontouchstart' in window || navigator.maxTouchPoints > 0
setIsTouchDevice(isTouch)
}
useEffect(() => {
checkIfTouchDevice()
window.addEventListener('resize', checkIfTouchDevice)
return () => {
window.removeEventListener('resize', checkIfTouchDevice)
}
}, [])
return !isTouchDevice || visibleOnTouch ? <CursorInner /> : null
}
export { Cursor }
export type { CursorProps }
Mouse only
The Cursor component is currently only supported on non-touch devices.
The Cursor Component is a custom, interactive cursor implementation using React and Framer Motion. It provides a dynamic cursor that responds to user interactions and changes appearance based on the elements it hovers over.
With ButtonGroup
import { ALargeSmall, ListOrdered, Redo2, Undo2 } from 'lucide-react'
import { Button, ButtonGroup } from '@/components/core/button'
import { Window } from '@/components/core/window'
import { Separator } from '@/components/ui/separator'
export const WithButtonGroup = () => {
return (
<Window>
<ButtonGroup>
<Button variant="secondary" size="icon">
<Undo2 data-slot="icon" />
</Button>
<Button variant="secondary" size="icon">
<Redo2 data-slot="icon" />
</Button>
<Separator orientation="vertical" className="h-8" />
<Button variant="secondary" size="icon">
<ALargeSmall data-slot="icon" />
</Button>
<Button variant="secondary" size="icon">
<ListOrdered data-slot="icon" />
</Button>
</ButtonGroup>
</Window>
)
}
Component Structure
The component is structured into two main parts:
CursorInner
: The core functionality of the custom cursor.Cursor
: A wrapper component that checks if the device is touch-enabled and rendersCursorInner
accordingly.
The Cursor component will automatically handle hiding the default cursor and showing the custom cursor on non-touch devices.
Performance Optimizations
- Use of Framer Motion's
frame
API to batch DOM reads, updates, and renders, preventing layout thrashing. - Efficient event handling with checks to prevent unnecessary updates.
- Use of springs and motion values for smooth, hardware-accelerated animations.
Usage
To use the Cursor component in your React application:
import { Cursor } from "@/components/core/cursor";
function App() {
return (
<div>
{/* Your app content */}
<Cursor />
</div>
);
}
API
Prop | Type | Default |
---|---|---|
visibleOnTouch? | boolean | false |
Edit on GitHub
Last updated on