wui
组件

悬停预览 Hover Preview

当鼠标悬停在列表行、链接或触发元素上时,在其近旁展示跟随弹簧浮动的图文富媒体预览卡片。

第三方依赖 · motion

基础用法

鼠标移至项目列表的任一行,封面图会以弹簧跟随指针,并随水平移动速度轻微倾斜;靠近视口右侧时自动翻转到指针左侧:

Loading…

安装与引入

通过 CLI 自动添加组件,或手动复制源码至项目中:

pnpm dlx @wui-design/cli@latest add @wui/hover-preview
安装基础依赖与动效库
pnpm add motion lucide-react class-variance-authority clsx tailwind-merge
复制组件源码到 components/ui/hover-preview.tsx
components/ui/hover-preview.tsx
"use client"

import * as React from "react"
import { createPortal } from "react-dom"
import {
  AnimatePresence,
  motion,
  useMotionValue,
  useReducedMotion,
  useSpring,
  useTransform,
  useVelocity,
  type HTMLMotionProps,
} from "motion/react"

import { cn } from "@/lib/utils"

export interface HoverPreviewProps extends Omit<
  HTMLMotionProps<"div">,
  "children"
> {
  /** Interactive row, link, or control that activates the preview. */
  children: React.ReactNode
  /** Visual content displayed beside the pointer. */
  preview: React.ReactNode
  /** Horizontal pointer offset in pixels. @default 24 */
  offsetX?: number
  /** Vertical pointer offset in pixels. @default 20 */
  offsetY?: number
  /** Maximum lean in degrees driven by horizontal pointer speed. `0` disables it. @default 6 */
  tilt?: number
  /** Controlled visibility. */
  open?: boolean
  /** Classes applied to the floating preview. */
  previewClassName?: string
}

const followSpring = { stiffness: 420, damping: 34, mass: 0.25 }
const EDGE = 12

/** Displays a spring-following visual preview for a hovered or focused row. */
function HoverPreview({
  children,
  preview,
  offsetX = 24,
  offsetY = 20,
  tilt = 6,
  open,
  className,
  previewClassName,
  onPointerEnter,
  onPointerMove,
  onPointerLeave,
  onFocus,
  onBlur,
  ...props
}: HoverPreviewProps) {
  const [hovered, setHovered] = React.useState(false)
  const [focused, setFocused] = React.useState(false)
  const [finePointer, setFinePointer] = React.useState(false)
  const [mounted, setMounted] = React.useState(false)
  const previewRef = React.useRef<HTMLDivElement>(null)
  const reduceMotion = useReducedMotion()
  const rawX = useMotionValue(0)
  const rawY = useMotionValue(0)
  const x = useSpring(rawX, followSpring)
  const y = useSpring(rawY, followSpring)
  const velocityX = useVelocity(x)
  const rotate = useTransform(velocityX, [-1600, 1600], [-tilt, tilt], {
    clamp: true,
  })
  const visible = open ?? ((hovered && finePointer) || focused)

  React.useEffect(() => {
    const media = window.matchMedia("(pointer: fine)")
    const update = () => setFinePointer(media.matches)
    update()
    setMounted(true)
    media.addEventListener("change", update)
    return () => media.removeEventListener("change", update)
  }, [])

  function setPosition(clientX: number, clientY: number, jump = false) {
    const width = previewRef.current?.offsetWidth ?? 0
    const height = previewRef.current?.offsetHeight ?? 0
    // Flip to the other side of the pointer when the preview would leave the viewport.
    const nextX =
      clientX + offsetX + width > window.innerWidth - EDGE
        ? clientX - offsetX - width
        : clientX + offsetX
    const nextY = Math.min(
      clientY + offsetY,
      window.innerHeight - height - EDGE
    )

    if (jump) {
      rawX.jump(nextX)
      rawY.jump(nextY)
      x.jump(nextX)
      y.jump(nextY)
      return
    }
    rawX.set(nextX)
    rawY.set(nextY)
  }

  return (
    <motion.div
      data-slot="hover-preview"
      className={cn("relative", className)}
      onPointerEnter={(event) => {
        if (event.pointerType !== "touch") {
          setPosition(event.clientX, event.clientY, !hovered)
          setHovered(true)
        }
        onPointerEnter?.(event)
      }}
      onPointerMove={(event) => {
        if (event.pointerType !== "touch")
          setPosition(event.clientX, event.clientY)
        onPointerMove?.(event)
      }}
      onPointerLeave={(event) => {
        setHovered(false)
        onPointerLeave?.(event)
      }}
      onFocus={(event) => {
        setFocused(true)
        const rect = event.currentTarget.getBoundingClientRect()
        setPosition(rect.right, rect.top + rect.height / 2, true)
        onFocus?.(event)
      }}
      onBlur={(event) => {
        if (!event.currentTarget.contains(event.relatedTarget))
          setFocused(false)
        onBlur?.(event)
      }}
      {...props}
    >
      {children}
      {mounted
        ? createPortal(
            <AnimatePresence>
              {visible ? (
                <motion.div
                  ref={previewRef}
                  aria-hidden="true"
                  data-slot="hover-preview-content"
                  className={cn(
                    "pointer-events-none fixed left-0 top-0 z-50 origin-top-left overflow-hidden",
                    previewClassName
                  )}
                  style={{
                    x: reduceMotion ? rawX : x,
                    y: reduceMotion ? rawY : y,
                    rotate: reduceMotion || !tilt ? 0 : rotate,
                  }}
                  initial={
                    reduceMotion
                      ? false
                      : { opacity: 0, scale: 0.9, filter: "blur(4px)" }
                  }
                  animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
                  exit={{ opacity: 0, scale: 0.95, filter: "blur(2px)" }}
                  transition={{
                    duration: reduceMotion ? 0 : 0.22,
                    ease: [0.22, 1, 0.36, 1],
                  }}
                >
                  {preview}
                </motion.div>
              ) : null}
            </AnimatePresence>,
            document.body
          )
        : null}
    </motion.div>
  )
}

export { HoverPreview }

属性 Props

属性类型默认值说明
childrenReact.ReactNode—接收悬停或焦点交互的目标行、链接或按钮元素。
previewReact.ReactNode—在浮动层中展示的预览内容(支持图片、指标卡片、作者信息等)。
offsetXnumber24预览卡片相对于鼠标水平位置的 X 轴偏移像素距离。
offsetYnumber20预览卡片相对于鼠标垂直位置的 Y 轴偏移像素距离。
tiltnumber6随指针水平速度产生的最大倾斜角度(度)。`0` 表示关闭。
openboolean—受控模式下显式控制预览卡片的展开/收起状态。
previewClassNamestring—应用于浮动预览卡片外层容器的 CSS 类名。
classNamestring—应用于外层触发容器的 CSS 类名。

事件 Events

属性类型默认值说明
onPointerEnter(event: React.PointerEvent) => void—光标进入目标交互行时触发。
onPointerMove(event: React.PointerEvent) => void—光标在目标行内移动时触发,驱动弹簧目标坐标更新。
onPointerLeave(event: React.PointerEvent) => void—光标离开目标交互行时触发,收起预览卡片。
onFocus(event: React.FocusEvent) => void—键盘通过 Tab 键聚焦到子元素时触发,自动在元素右侧锚定展示预览。
onBlur(event: React.FocusEvent) => void—失去键盘焦点时触发,收起预览卡片。

使用场景与设计规范

HoverPreview 适用于不离开当前浏览流的前提下提供关键上下文摘要:

  • 链接文章卡片预览:在长文本或博客中,悬停在引用链接上直接展示文章大图与阅读时长。
  • 用户头像名片:在评论区或协作者列表中,悬停在用户名上展示作者职位、关注按钮与社交信息。
  • 作品与项目列表:在案例列表中快速预览封面图,无需进入详情页。

预览层通过 Portal 渲染到 document.body,不会被父级的 overflow 或 transform 裁切;每次进入时直接定位到指针处,不会从上一次的位置飞入。

场景示例

文本链接卡片预览

在正文中的引用链接上展示文章摘要,文字类预览建议设置 tilt={0}:

动效的时长并不是越长越好。我们在 一文中比较了三种曲线,结论是:界面内的小变化控制在 200ms 左右,用户感知最自然。

用户资料名片

悬停于评论者名字展示个人资料。预览层不响应指针事件,请不要在其中放置按钮等可交互元素:

Loading…

无障碍与交互 Accessibility

  • 键盘焦点支持:不仅支持鼠标悬停,通过 Tab 聚焦行内链接时,组件能自动获取元素在视口中的绝对包围盒并展示预览。
  • 触摸屏降级:通过指针类型探测(pointerType !== "touch")自动屏蔽移动端误触,避免在手机滚动时弹出悬浮层遮挡视线。
  • 无障碍标记:浮动预览层使用 aria-hidden="true" 和 pointer-events-none,不会干扰屏幕阅读器按序朗读主干内容。