组件
自定义光标 Cursor
附着于页面或特定容器的物理弹簧跟随自定义光标,为创意展示与画布操作提供高交互沉浸感。
基础用法
移动鼠标进入指定区域,原生鼠标指针将自动隐藏,并被弹簧物理缓动的自定义视觉元素接管:
把鼠标移入此区域
自定义光标以弹簧跟随指针,只在当前区域内生效
安装与引入
通过 CLI 自动添加组件,或手动复制源码至项目中:
pnpm dlx @wui-design/cli@latest add @wui/cursor安装基础依赖与动效库
pnpm add motion lucide-react class-variance-authority clsx tailwind-merge复制组件源码到
components/ui/cursor.tsx"use client"
import * as React from "react"
import {
AnimatePresence,
motion,
useMotionValue,
useReducedMotion,
useSpring,
type HTMLMotionProps,
type SpringOptions,
type Transition,
type Variants,
} from "motion/react"
import { cn } from "@/lib/utils"
type CursorPosition = { x: number; y: number }
const defaultVariants: Variants = {
initial: { opacity: 0, scale: 0.75 },
animated: { opacity: 1, scale: 1 },
exit: { opacity: 0, scale: 0.75 },
}
export interface CursorProps extends Omit<
HTMLMotionProps<"div">,
"children" | "transition" | "variants"
> {
/** Visual rendered at the pointer position. */
children: React.ReactNode
/** Spring physics used to follow the pointer. */
springConfig?: SpringOptions
/** Limit the cursor to the component's parent element. @default false */
attachToParent?: boolean
/** Entrance and exit transition. */
transition?: Transition
/** Initial, animated and exit states for the cursor visual. */
variants?: Variants
/** Hide the platform cursor over the active target. @default true */
hideNativeCursor?: boolean
/** Called whenever the pointer coordinates change. */
onPositionChange?: (position: CursorPosition) => void
}
/** A spring-following custom cursor for the page or a parent region. */
function Cursor({
children,
className,
style,
springConfig = { stiffness: 500, damping: 35, mass: 0.2 },
attachToParent = false,
transition = { duration: 0.15 },
variants = defaultVariants,
hideNativeCursor = true,
onPositionChange,
...props
}: CursorProps) {
const anchorRef = React.useRef<HTMLSpanElement>(null)
const [visible, setVisible] = React.useState(false)
const [finePointer, setFinePointer] = React.useState(false)
const reduceMotion = useReducedMotion()
const rawX = useMotionValue(0)
const rawY = useMotionValue(0)
const springX = useSpring(rawX, springConfig)
const springY = useSpring(rawY, springConfig)
const visibleRef = React.useRef(false)
const onPositionChangeRef = React.useRef(onPositionChange)
React.useEffect(() => {
onPositionChangeRef.current = onPositionChange
})
React.useEffect(() => {
const query = window.matchMedia("(pointer: fine)")
const update = () => setFinePointer(query.matches)
update()
query.addEventListener("change", update)
return () => query.removeEventListener("change", update)
}, [])
React.useEffect(() => {
const anchor = anchorRef.current
const target = attachToParent ? anchor?.parentElement : window
if (!target || !finePointer) return
const cursorTarget = attachToParent
? anchor?.parentElement
: document.documentElement
const previousCursor = cursorTarget?.style.cursor
if (cursorTarget && hideNativeCursor) cursorTarget.style.cursor = "none"
const move = (event: Event) => {
const pointerEvent = event as PointerEvent
let x = pointerEvent.clientX
let y = pointerEvent.clientY
if (attachToParent && anchor?.parentElement) {
const parent = anchor.parentElement
const rect = parent.getBoundingClientRect()
x = pointerEvent.clientX - rect.left - parent.clientLeft + parent.scrollLeft
y = pointerEvent.clientY - rect.top - parent.clientTop + parent.scrollTop
}
if (!visibleRef.current) {
// Appear exactly under the pointer instead of flying in from the
// previous position (or the top-left corner on first entry).
rawX.jump(x)
rawY.jump(y)
springX.jump(x)
springY.jump(y)
visibleRef.current = true
setVisible(true)
} else {
rawX.set(x)
rawY.set(y)
}
onPositionChangeRef.current?.({ x, y })
}
const leave = () => {
visibleRef.current = false
setVisible(false)
}
target.addEventListener("pointermove", move)
target.addEventListener("pointerenter", move)
target.addEventListener("pointerleave", leave)
return () => {
target.removeEventListener("pointermove", move)
target.removeEventListener("pointerenter", move)
target.removeEventListener("pointerleave", leave)
if (cursorTarget && hideNativeCursor)
cursorTarget.style.cursor = previousCursor ?? ""
}
}, [attachToParent, finePointer, hideNativeCursor, rawX, rawY, springX, springY])
const x = reduceMotion ? rawX : springX
const y = reduceMotion ? rawY : springY
return (
<>
<span ref={anchorRef} aria-hidden className="hidden" />
<AnimatePresence>
{visible && finePointer ? (
<motion.div
aria-hidden="true"
data-slot="cursor"
className={cn(
"pointer-events-none left-0 top-0 z-50 -translate-x-1/2 -translate-y-1/2",
attachToParent ? "absolute" : "fixed",
className
)}
style={{ ...style, x, y }}
variants={variants}
initial={reduceMotion ? false : "initial"}
animate="animated"
exit="exit"
transition={reduceMotion ? { duration: 0 } : transition}
{...props}
>
{children}
</motion.div>
) : null}
</AnimatePresence>
</>
)
}
export { Cursor }
属性 Props
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| children | React.ReactNode | — | 渲染在光标位置的视觉内容(如自定义图标、徽章、跟随文字或操作提示)。 |
| attachToParent | boolean | false | 是否将光标的作用范围严格限制在父级容器元素内部(为 false 时作用于全局窗口)。 |
| springConfig | SpringOptions | { stiffness: 500, damping: 35, mass: 0.2 } | 跟随指针位移时所使用的 Motion 弹簧物理参数配置。 |
| hideNativeCursor | boolean | true | 在目标交互区域内是否隐藏系统原生鼠标指针。 |
| variants | Variants | { initial: { opacity: 0, scale: 0.75 }, animated: { opacity: 1, scale: 1 }, exit: { opacity: 0, scale: 0.75 } } | 光标进入与离开目标交互区域时的进出场变体动画。 |
| onPositionChange | ({ x, y }: { x: number; y: number }) => void | — | 当指针坐标发生变化时实时触发的回调函数,返回相对坐标值。 |
| className | string | — | 应用于自定义光标浮动外层的 CSS 类名。 |
事件 Events
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| onPositionChange | ({ x, y }: { x: number; y: number }) => void | — | 鼠标在目标区域移动时触发,输出高精度的相对 X/Y 像素坐标。 |
| onPointerEnter | (event: PointerEvent) => void | — | 光标进入有效监测区域时触发。 |
| onPointerLeave | (event: PointerEvent) => void | — | 光标离开有效监测区域时触发。 |
使用场景与设计规范
Cursor 适用于画廊、视频播放器封面、三维/空间画布等需要强沉浸视觉引导的场景:
- 局部限定使用(attachToParent):通常推荐将
attachToParent={true}约束在特定创意模块或卡片内,避免接管整个应用全局光标造成常规操作困惑。 - 触控设备自动抑制:组件内置检测
(pointer: fine)媒体查询。在手机与平板等触控设备上,组件会自动静默关闭并禁用弹簧更新,保证触控性能与正常滚动。 - 就地出现:指针进入区域时光标会直接出现在指针所在位置再开始弹簧跟随,不会从上一次离开的位置或左上角“飞入”。
- 轻量与响应性:避免在自定义光标内部渲染过于沉重的复杂 DOM 结构,弹簧的
mass建议保持在 0.15 ~ 0.3 以确保跟手性。
场景示例
容器边界与坐标监听
将光标限定在父级工作区,并实时读取指针相对坐标:
画布取点
通过 onPositionChange 实时读取光标在容器内的坐标
创意卡片交互
为媒体卡片与案例研究提供带有操作暗示(如“播放”、“探索”)的悬浮光标指示:
Loading…
无障碍与交互 Accessibility
- 指针精准度适配:通过
window.matchMedia("(pointer: fine)")自动识别设备类型,仅对高精度鼠标/触控板设备启用自定义渲染。 - 动态效果减弱:当检测到
prefers-reduced-motion: reduce时,弹簧物理平滑计算将自动降级为即时坐标对齐,避免视力或前庭系统敏感用户的晕眩。 - 不可阻挡原生事件:光标容器默认标记
pointer-events: none与aria-hidden="true",不会阻挡底层按钮、链接的点击与屏幕阅读器无障碍树解析。

