wui
组件

自定义光标 Cursor

可全局使用或附着到局部区域的弹簧跟随光标。

基础示例

Move inside this area

弹簧光标仅附着在当前区域

pnpm dlx wui@latest add @wui/cursor
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)

  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
      }
      rawX.set(x)
      rawY.set(y)
      setVisible(true)
      onPositionChange?.({ x, y })
    }
    const leave = () => 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,
    onPositionChange,
    rawX,
    rawY,
  ])

  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 }

组件作用

Cursor 用视觉反馈补充作品展示、图片探索等指针交互。attachToParent 会把活动范围限制在直接父元素;触摸设备和非精确指针不会显示自定义光标。

不要依赖光标传达必要信息

自定义光标会被辅助技术忽略。操作含义、状态和文本仍应在页面内容中完整表达。

组件属性

PropTypeDefaultDescription
children *ReactNodeVisual rendered at the pointer position.
springConfigSpringOptions{ stiffness: 500, damping: 35, mass: 0.2 }Spring physics used to follow the pointer.
attachToParentbooleanfalseLimit the cursor to the component's parent element.
transitionTransition{ duration: 0.15 }Entrance and exit transition.
variantsVariants{ initial: { opacity: 0, scale: 0.75 }, animated: { opacity: 1, scale: 1 }, exit: { opacity: 0, scale: 0.75 }, }Initial, animated and exit states for the cursor visual.
hideNativeCursorbooleantrueHide the platform cursor over the active target.
onPositionChange((position: CursorPosition) => void)Called whenever the pointer coordinates change.

事件

onPositionChange 返回目标坐标系内的 { x, y }。容器事件仍由父元素自行处理,光标视觉设置了 pointer-events: none,不会阻断点击。

扩展使用

不设置 attachToParent 时,组件会跟随整个页面;可以通过 springConfig 调整跟手或拖尾感,并用 variants 自定义出现与退出状态。