wui
组件

自定义光标 Cursor

附着于页面或特定容器的物理弹簧跟随自定义光标,为创意展示与画布操作提供高交互沉浸感。

第三方依赖 · motion

基础用法

移动鼠标进入指定区域,原生鼠标指针将自动隐藏,并被弹簧物理缓动的自定义视觉元素接管:

把鼠标移入此区域

自定义光标以弹簧跟随指针,只在当前区域内生效

安装与引入

通过 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
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

属性类型默认值说明
childrenReact.ReactNode—渲染在光标位置的视觉内容(如自定义图标、徽章、跟随文字或操作提示)。
attachToParentbooleanfalse是否将光标的作用范围严格限制在父级容器元素内部(为 false 时作用于全局窗口)。
springConfigSpringOptions{ stiffness: 500, damping: 35, mass: 0.2 }跟随指针位移时所使用的 Motion 弹簧物理参数配置。
hideNativeCursorbooleantrue在目标交互区域内是否隐藏系统原生鼠标指针。
variantsVariants{ 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—当指针坐标发生变化时实时触发的回调函数,返回相对坐标值。
classNamestring—应用于自定义光标浮动外层的 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",不会阻挡底层按钮、链接的点击与屏幕阅读器无障碍树解析。