组件
悬停预览 Hover Preview
当鼠标悬停在列表行、链接或触发元素上时,在其近旁展示跟随弹簧浮动的图文富媒体预览卡片。
基础用法
鼠标移至项目列表的任一行,封面图会以弹簧跟随指针,并随水平移动速度轻微倾斜;靠近视口右侧时自动翻转到指针左侧:
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"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
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| children | React.ReactNode | — | 接收悬停或焦点交互的目标行、链接或按钮元素。 |
| preview | React.ReactNode | — | 在浮动层中展示的预览内容(支持图片、指标卡片、作者信息等)。 |
| offsetX | number | 24 | 预览卡片相对于鼠标水平位置的 X 轴偏移像素距离。 |
| offsetY | number | 20 | 预览卡片相对于鼠标垂直位置的 Y 轴偏移像素距离。 |
| tilt | number | 6 | 随指针水平速度产生的最大倾斜角度(度)。`0` 表示关闭。 |
| open | boolean | — | 受控模式下显式控制预览卡片的展开/收起状态。 |
| previewClassName | string | — | 应用于浮动预览卡片外层容器的 CSS 类名。 |
| className | string | — | 应用于外层触发容器的 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,不会干扰屏幕阅读器按序朗读主干内容。