wui
组件

透视倾斜 Tilt Card

卡片跟随光标指针在三维空间中产生细腻的 3D 透视倾斜与反光眩光,并基于物理弹簧平滑回正。

第三方依赖 · motion

基础用法

悬停在作品卡片上,卡片随指针产生 3D 倾斜并淡入高光,底部信息栏通过 translateZ 浮在表面之上:

Loading…

安装与引入

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

pnpm dlx @wui-design/cli@latest add @wui/tilt-card
安装动效依赖与工具库
pnpm add motion clsx tailwind-merge
复制组件源码到 components/ui/tilt-card.tsx
components/ui/tilt-card.tsx
"use client"

import * as React from "react"
import {
  motion,
  useMotionTemplate,
  useMotionValue,
  useReducedMotion,
  useSpring,
  type HTMLMotionProps,
} from "motion/react"

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

export interface TiltCardProps extends Omit<
  HTMLMotionProps<"div">,
  "children"
> {
  /** Content rendered on the tilting plane. */
  children: React.ReactNode
  /** Maximum rotation on either axis in degrees. @default 9 */
  maxTilt?: number
  /** Perspective depth in pixels. @default 900 */
  perspective?: number
  /** Scale applied while the pointer is over the card. @default 1.015 */
  hoverScale?: number
  /** Show a pointer-position glare layer that fades in on hover. @default false */
  glare?: boolean
  /** Peak opacity of the glare layer. @default 0.35 */
  glareOpacity?: number
  /** Classes applied to the optional glare layer. */
  glareClassName?: string
}

const tiltSpring = { stiffness: 220, damping: 24, mass: 0.45 }

/** Tilts a surface in 3D according to the pointer position and springs to rest. */
function TiltCard({
  children,
  maxTilt = 9,
  perspective = 900,
  hoverScale = 1.015,
  glare = false,
  glareOpacity = 0.35,
  className,
  glareClassName,
  style,
  onPointerEnter,
  onPointerMove,
  onPointerLeave,
  ...props
}: TiltCardProps) {
  const reduceMotion = useReducedMotion()
  // Measured at rest on enter; reading the rect of a tilted card makes the input feed back into itself.
  const rectRef = React.useRef<DOMRect | null>(null)
  const rotateXValue = useMotionValue(0)
  const rotateYValue = useMotionValue(0)
  const scaleValue = useMotionValue(1)
  const glareVisibility = useMotionValue(0)
  const glareX = useMotionValue(50)
  const glareY = useMotionValue(50)
  const rotateX = useSpring(rotateXValue, tiltSpring)
  const rotateY = useSpring(rotateYValue, tiltSpring)
  const scale = useSpring(scaleValue, { stiffness: 300, damping: 26 })
  const glareAlpha = useSpring(glareVisibility, { stiffness: 160, damping: 26 })
  const glareBackground = useMotionTemplate`radial-gradient(circle at ${glareX}% ${glareY}%, white, transparent 52%)`

  function handleMove(event: React.PointerEvent<HTMLDivElement>) {
    const rect = rectRef.current ?? event.currentTarget.getBoundingClientRect()
    const x = Math.min(Math.max((event.clientX - rect.left) / rect.width, 0), 1)
    const y = Math.min(Math.max((event.clientY - rect.top) / rect.height, 0), 1)
    rotateXValue.set((0.5 - y) * maxTilt * 2)
    rotateYValue.set((x - 0.5) * maxTilt * 2)
    glareX.set(x * 100)
    glareY.set(y * 100)
  }

  return (
    <motion.div
      data-slot="tilt-card"
      className={cn("relative transform-gpu", className)}
      style={{
        ...style,
        rotateX: reduceMotion ? 0 : rotateX,
        rotateY: reduceMotion ? 0 : rotateY,
        scale: reduceMotion ? 1 : scale,
        transformPerspective: perspective,
        transformStyle: "preserve-3d",
      }}
      onPointerEnter={(event) => {
        if (!reduceMotion && event.pointerType !== "touch") {
          rectRef.current = event.currentTarget.getBoundingClientRect()
          scaleValue.set(hoverScale)
          glareVisibility.set(glareOpacity)
          handleMove(event)
        }
        onPointerEnter?.(event)
      }}
      onPointerMove={(event) => {
        if (!reduceMotion && event.pointerType !== "touch") handleMove(event)
        onPointerMove?.(event)
      }}
      onPointerLeave={(event) => {
        rectRef.current = null
        rotateXValue.set(0)
        rotateYValue.set(0)
        scaleValue.set(1)
        glareVisibility.set(0)
        onPointerLeave?.(event)
      }}
      {...props}
    >
      {children}
      {glare && !reduceMotion ? (
        <motion.div
          aria-hidden="true"
          data-slot="tilt-card-glare"
          className={cn(
            "pointer-events-none absolute inset-0 rounded-[inherit] mix-blend-soft-light",
            glareClassName
          )}
          style={{ background: glareBackground, opacity: glareAlpha }}
        />
      ) : null}
    </motion.div>
  )
}

export { TiltCard }

属性 Props

TiltCard 接受以下 3D 动效与光效配置属性,并继承 motion.div 的全部 HTML 属性:

属性类型默认值说明
childrenReact.ReactNode—卡片内部渲染的内容,可结合 CSS translateZ 建立多维纵深分层。
maxTiltnumber9卡片在 X / Y 轴方向上的最大倾斜旋转角度(度 deg)。建议保持在 6~12 度之间。
perspectivenumber900三维透视距离(像素 px)。数值越大透视越平缓,数值越小空间畸变感越强。
hoverScalenumber1.015光标悬停在卡片上方时的微缩放倍率。
glarebooleanfalse是否开启随光标坐标移动的表面高光图层。高光仅在悬停时淡入。
glareOpacitynumber0.35悬停时高光层淡入到的峰值不透明度;指针离开时随弹簧淡出。
glareClassNamestring—应用于高光图层的额外 CSS 类名(如 mix-blend 混合模式)。不透明度请使用 `glareOpacity`。
classNamestring—应用于外层卡片容器的额外 CSS 类名。

事件 Events

TiltCard 继承 Motion 容器的事件回调:

属性类型默认值说明
onPointerMove(event: React.PointerEvent<HTMLDivElement>) => void—指针在卡片内移动时触发,内部通过 MotionValue 计算 X/Y 倾角并触发物理弹簧插值。
onPointerLeave(event: React.PointerEvent<HTMLDivElement>) => void—指针离开卡片时触发,内部自动通过 Spring 回弹归零复位。

使用场景与设计规范

TiltCard 为界面带来触手可及的“实体感”与空间交互魅力。

  • 使用场景:
    • 数字会员卡 / 凭证(Pass & Badges):极具科技感的开发者通行证、VIP 权益卡。
    • 核心作品 / 摄影与艺术案例:作品集封面、获奖项目展示。
    • 旗舰硬件 / 核心特性展示:让用户在鼠标滑动中全方位感受产品的精细质感。
  • 3D 分层与纵深技巧:
    • 卡片内部容器默认启用了 transform-style: preserve-3d。
    • 在内部子元素上添加 Tailwind 类名 [transform:translateZ(24px)] 或 [transform:translateZ(36px)],可以使文字和图标“悬浮”在卡片表面上方,产生视差立体感。
    • 注意:overflow-hidden 会强制扁平化 3D 上下文,使 translateZ 失效。需要裁切图片时,请把 overflow-hidden 放在图片自身或内层容器上,而不是卡片根节点。
    • 指针位置基于进入时测得的静止尺寸计算,避免倾斜后的包围盒反过来影响角度,边缘处不会抖动。
  • 克制设计原则:
    • 避免在密集的数据表格行或长列表项中过度使用 3D 倾斜,以免干扰用户的浏览与点击精度。

场景示例

会员卡

三层内容分别使用不同的 translateZ 深度,配合高光打造会员卡的实体感:

Loading…

无障碍与交互 Accessibility

  • 自动无障碍降级:内置 useReducedMotion 监听。当系统开启了“减少动态效果”时,3D 旋转与缩放动效自动归零(Static 0deg / 1.0 Scale),高光层不渲染,避免任何视觉眩晕。
  • 触摸屏保护:在移动端触摸(Touch)环境下自动跳过倾斜计算,避免与页面上下滑动手势冲突。
  • 反光层穿透:高光层标记为 aria-hidden="true" 且带有 pointer-events-none,不遮挡内部可聚焦元素。