鼠标 3D 倾斜卡片

鼠标 3D 倾斜卡片

一张带有鼠标跟随效果的卡片,并不一定要承担点击、跳转或按钮操作。

本文实现的是一个纯展示型 的柔和美学卡片:鼠标移入时,卡片会随指针位置产生轻微的 3D 倾斜;鼠标离开后平缓复位。卡片本身不使用 <a><button>,也没有 click 点击事件,因此不会误导用户将展示内容理解为可操作控件。

实现重点不只是"让卡片转起来",而是将视觉职责、事件职责与降级策略拆开:

  • article 表达一块独立展示内容;
  • .tilt-scene 提供透视环境;
  • .frame 处理旋转、阴影和流光边框;
  • .card 处理内容背景、排版和指针柔光;
  • JavaScript 只将指针坐标映射为 CSS 自定义属性;
  • 同一绘制帧内的多次坐标变化会被合并,避免重复更新样式;
  • 触摸设备与"减少动态效果"模式下保留静态展示。

结构:拆开视觉职责

卡片由外到内分为三层:透视场景、流光外框与内容表面。

text 复制代码
页面背景
└─ 3D 透视容器 .tilt-scene
   └─ 流光外框 .frame
      ├─ 流光层 .frame::before
      └─ 内容卡片 .card
         ├─ 指针柔光 .card::after
         └─ 文字内容 .card__content

外层 .tilt-scene 提供 perspective,使内部旋转元素具备透视感;.frame 是实际旋转的对象,同时承载阴影和圆锥渐变流光;.card 覆盖在流光层上方,遮住中间区域,只留下约 2px 的彩色边缘。

这种分层方式避免把渐变描边、内容背景、文字排版和 3D 变换堆叠在同一个元素上。后续调整边框厚度、背景透明度或阴影时,通常只需修改对应层。

完整示例

将以下内容保存为 index.html 后直接打开即可。

html 复制代码
<!doctype html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>鼠标 3D 倾斜卡片</title>

  <style>
    :root {
      --page: #f4f1e9;
      --card-bg: #eef2e8;
      --ink: #2f3b34;
      --muted: #718078;
      --line: #d7ddd2;

      --radius: 28px;
      --perspective: 1200px;
      --max-tilt: 6;
    }

    * {
      box-sizing: border-box;
    }

    html,
    body {
      margin: 0;
    }

    body {
      min-height: 100vh;
      padding: 24px;

      display: grid;
      place-items: center;

      overflow-x: hidden;

      color: var(--ink);

      font-family:
        Inter,
        ui-sans-serif,
        system-ui,
        -apple-system,
        BlinkMacSystemFont,
        "Segoe UI",
        "PingFang SC",
        "Microsoft YaHei",
        sans-serif;

      background: var(--page);
    }

    .tilt-scene {
      width: min(100%, 620px);
      perspective: var(--perspective);
    }

    .frame {
      --rotate-x: 0deg;
      --rotate-y: 0deg;
      --pointer-x: 50%;
      --pointer-y: 50%;

      position: relative;

      padding: 1px;

      border: 1px solid var(--line);
      border-radius: calc(var(--radius) + 1px);

      background: #ffffff;

      box-shadow:
        0 24px 55px rgb(49 61 53 / 12%),
        0 6px 16px rgb(49 61 53 / 6%);

      transform:
        rotateX(var(--rotate-x))
        rotateY(var(--rotate-y));

      transform-style: preserve-3d;

      transition:
        transform 520ms cubic-bezier(.16, 1, .3, 1),
        box-shadow 520ms cubic-bezier(.16, 1, .3, 1);
    }

    .frame.is-tilting {
      will-change: transform;

      box-shadow:
        0 32px 68px rgb(49 61 53 / 15%),
        0 10px 22px rgb(49 61 53 / 8%);

      transition-duration: 70ms;
      transition-timing-function: linear;
    }

    .card {
      position: relative;

      min-height: 410px;
      padding: clamp(28px, 6vw, 46px);

      overflow: hidden;

      border-radius: var(--radius);

      background: var(--card-bg);
    }

    .card::after {
      position: absolute;
      inset: 0;

      content: "";
      pointer-events: none;

      opacity: 0.5;

      background:
        radial-gradient(
          circle 230px at var(--pointer-x) var(--pointer-y),
          rgb(255 255 255 / 75%),
          transparent 70%
        );
    }

    .card__content {
      position: relative;
      z-index: 1;

      display: flex;
      flex-direction: column;

      min-height: 316px;

      transform: translateZ(22px);
    }

    .eyebrow {
      width: fit-content;

      margin: 0 0 28px;
      padding: 8px 13px;

      border: 1px solid #ccd5c8;
      border-radius: 999px;

      color: #617068;
      background: #f8faf5;

      font-size: 12px;
      font-weight: 500;
      letter-spacing: 0.08em;
    }

    h1 {
      max-width: 8em;
      margin: 0;

      color: var(--ink);

      font-family:
        Georgia,
        "Times New Roman",
        "Noto Serif SC",
        serif;

      font-size: clamp(2.8rem, 8vw, 5.1rem);
      font-weight: 500;
      line-height: 1;
      letter-spacing: -0.065em;

      text-wrap: balance;
    }

    .description {
      max-width: 30rem;

      margin: 24px 0 0;

      color: var(--muted);

      font-size: 15px;
      line-height: 1.9;
    }

    .meta {
      display: flex;
      align-items: center;
      gap: 12px;

      margin: auto 0 0;
      padding-top: 40px;

      color: #77837c;

      font-size: 13px;
      letter-spacing: 0.03em;
    }

    .meta::before {
      width: 34px;
      height: 1px;

      content: "";

      background: currentColor;
      opacity: 0.5;
    }

    @media (max-width: 600px) {
      body {
        padding: 16px;
      }

      .card {
        min-height: 380px;
        padding: 30px 26px;
      }

      .card__content {
        min-height: 318px;
      }

      h1 {
        font-size: clamp(2.7rem, 14vw, 4.2rem);
      }

      .description {
        font-size: 14px;
        line-height: 1.8;
      }

      .meta {
        padding-top: 34px;
      }
    }

    @media (hover: none), (pointer: coarse) {
      .card__content {
        transform: none;
      }
    }

    @media (prefers-reduced-motion: reduce) {
      .frame,
      .frame.is-tilting {
        transform: none !important;
        transition: none;
      }
    }
  </style>
</head>

<body>
  <main class="tilt-scene">
    <article class="frame" aria-labelledby="card-title">
      <div class="card">
        <div class="card__content">
          <p class="eyebrow">SPRING NOTES · 2026</p>

          <h1 id="card-title">
            留一点轻盈,给日常。
          </h1>

          <p class="description">
            用低饱和色彩、柔和阴影和细微的空间变化,
            让一张静态卡片也保有简单而安静的呼吸感。
          </p>

          <p class="meta">
            A quiet visual study
          </p>
        </div>
      </div>
    </article>
  </main>

  <script>
    const frame = document.querySelector('.frame');

    const canHover = window.matchMedia(
      '(hover: hover) and (pointer: fine)'
    );

    const reduceMotion = window.matchMedia(
      '(prefers-reduced-motion: reduce)'
    );

    let rect;
    let animationFrameId = 0;
    let nextX = 0.5;
    let nextY = 0.5;

    function resetFrame() {
      cancelAnimationFrame(animationFrameId);

      animationFrameId = 0;
      rect = undefined;

      frame.style.setProperty('--rotate-x', '0deg');
      frame.style.setProperty('--rotate-y', '0deg');
      frame.style.setProperty('--pointer-x', '50%');
      frame.style.setProperty('--pointer-y', '50%');

      frame.classList.remove('is-tilting');
    }

    function renderTilt() {
      animationFrameId = 0;

      const maxTilt = Number(
        getComputedStyle(document.documentElement)
          .getPropertyValue('--max-tilt')
      );

      const offsetX = (nextX - 0.5) * 2;
      const offsetY = (nextY - 0.5) * 2;

      frame.style.setProperty(
        '--rotate-y',
        `${(offsetX * maxTilt).toFixed(2)}deg`
      );

      frame.style.setProperty(
        '--rotate-x',
        `${(offsetY * -maxTilt).toFixed(2)}deg`
      );

      frame.style.setProperty(
        '--pointer-x',
        `${(nextX * 100).toFixed(1)}%`
      );

      frame.style.setProperty(
        '--pointer-y',
        `${(nextY * 100).toFixed(1)}%`
      );
    }

    function handlePointerEnter() {
      if (!canHover.matches || reduceMotion.matches) {
        return;
      }

      rect = frame.getBoundingClientRect();
      frame.classList.add('is-tilting');
    }

    function handlePointerMove(event) {
      if (!canHover.matches || reduceMotion.matches) {
        return;
      }

      rect ??= frame.getBoundingClientRect();

      nextX = Math.min(
        1,
        Math.max(
          0,
          (event.clientX - rect.left) / rect.width
        )
      );

      nextY = Math.min(
        1,
        Math.max(
          0,
          (event.clientY - rect.top) / rect.height
        )
      );

      if (!animationFrameId) {
        animationFrameId = requestAnimationFrame(renderTilt);
      }
    }

    frame.addEventListener(
      'pointerenter',
      handlePointerEnter
    );

    frame.addEventListener(
      'pointermove',
      handlePointerMove
    );

    frame.addEventListener(
      'pointerleave',
      resetFrame
    );

    window.addEventListener('resize', () => {
      rect = undefined;
    });

    canHover.addEventListener(
      'change',
      resetFrame
    );

    reduceMotion.addEventListener(
      'change',
      resetFrame
    );
  </script>
</body>
</html>

技术分享:从指针坐标到 3D 旋转

实现核心是将指针位置标准化为卡片内部的相对坐标,再以卡片中心为原点计算旋转角度。

设卡片左上角为 (left, top),尺寸为 (width, height),指针在视口中的坐标为 (clientX, clientY),则卡片内部坐标为:

js 复制代码
nextX = (event.clientX - rect.left) / rect.width;
nextY = (event.clientY - rect.top) / rect.height;

nextXnextY 的理想范围是 01:左上角接近 (0, 0),中心接近 (0.5, 0.5),右下角接近 (1, 1)。示例通过 Math.min()Math.max() 限制范围,避免指针位于边缘时产生超出预期的旋转值。

接着,将 0~1 的坐标转换为以中心为原点的 -1~1

js 复制代码
const offsetX = (nextX - 0.5) * 2;
const offsetY = (nextY - 0.5) * 2;

横向偏移控制 rotateY(),纵向偏移控制 rotateX();纵向旋转乘以负号,是为了让卡片朝更符合视觉直觉的方向倾斜。

最终角度由偏移量乘以 --max-tilt 得到:

js 复制代码
frame.style.setProperty('--rotate-y', `${offsetX * maxTilt}deg`);
frame.style.setProperty('--rotate-x', `${offsetY * -maxTilt}deg`);

通过 CSS 自定义属性传递数值,而不是在 JavaScript 中直接拼接完整的 transform 字符串,可以让 CSS 继续负责变换声明、过渡曲线和视觉样式,JavaScript 则只处理输入数据。

为什么用 requestAnimationFrame() 合并更新

pointermove 可能高频触发。若每次事件回调都立即写入多个样式属性,就可能在一次屏幕刷新前重复执行多轮更新。

本例在事件发生时只保存最新坐标;如果当前没有待执行的绘制任务,再请求一次 requestAnimationFrame()

js 复制代码
if (!animationFrameId) {
  animationFrameId = requestAnimationFrame(renderTilt);
}

由于同一帧内只保留一个回调,后续到达的 pointermove 只会覆盖 nextXnextY。因此,renderTilt() 读取的是该帧中最新的指针位置。

这不是持续运行的 JavaScript 动画:每次 requestAnimationFrame() 都是一次性的,回调执行后会将 animationFrameId 重置为 0。只有指针继续移动时,才会安排下一次视觉更新。浏览器通常会降低或暂停后台页面的动画帧回调,因此无需为不可见页面持续执行此效果。

为什么需要 perspective

.tilt-sceneperspective: 1200px 定义观察内部 3D 变换时的透视距离。数值较大时,透视效果更平缓;对于低饱和、柔和风格的卡片,较大的数值能避免夸张的空间扭曲。

本示例将 .frame 作为整体旋转对象,因此卡片、边框、阴影和文字会随同一个透视平面倾斜。代码没有使用 translateZ() 制造文字与卡片表面的独立三维深度:.frame.card 都使用了 overflow: hidden 来裁切流光和柔光,而这类裁切会使后代的 3D 场景被扁平化。对这一展示型组件而言,保持裁切边界和视觉稳定性比增加微弱的文字悬浮更重要。

若确实需要可见的多层 3D 深度,应重新设计不依赖 overflow: hidden 的裁切方案,并结合 transform-style: preserve-3dtranslateZ() 与额外的遮罩层测试各浏览器表现。

没有点击事件,但保留鼠标视觉反馈

示例没有:

  • click 事件;
  • 跳转链接;
  • 按钮行为;
  • 点击后的状态切换。

保留的 pointerenterpointermovepointerleave 只用于更新视觉状态:进入时读取卡片尺寸并切换为快速跟随的过渡;移动时更新旋转角度与柔光位置;离开时回到初始状态。

Pointer Events 为鼠标、触控笔和触摸输入提供统一的事件模型。本组件只在 (hover: hover) and (pointer: fine) 匹配时启用倾斜逻辑,因此不具备精细悬停能力的设备会保留静态展示,不会因滚动手势触发无意义的跟随效果。

展示内容不应仅仅因为拥有悬停动效就伪装成可点击控件。如果未来需求明确为"前往作品详情",应使用带 href<a>;如果需求是"执行动作",应使用 <button>。原生元素自带相应的语义与键盘交互预期,比给普通容器临时绑定点击行为更可靠。

可用性与降级策略

动态效果不应成为阅读内容的前提,因此示例提供两层降级。

1. 触摸或粗粒度指针设备

css 复制代码
@media (hover: none), (pointer: coarse) {
  .frame::before {
    animation: none;
  }
}

触摸设备通常没有稳定的悬停状态。这里关闭持续流光,同时保留卡片边框、排版和背景层次。

2. 系统"减少动态效果"偏好

css 复制代码
@media (prefers-reduced-motion: reduce) {
  .frame,
  .frame.is-tilting {
    transform: none !important;
    transition: none;
  }

  .frame::before {
    animation: none;
  }
}

prefers-reduced-motion 用于识别用户是否在系统辅助功能设置中表达了减少非必要动态效果的偏好。对于可能引发不适的旋转或持续动画,静态替代不是功能缺失,而是组件应有的可用性设计。

可调整参数

参数 当前值 建议范围 作用
--max-tilt 6 3~8 倾斜角度,数值越大越明显。
--perspective 1200px 1000px~1500px 数值越大,透视感越平缓。
--radius 32px 24px~40px 圆角越大,整体气质越柔和。
流光动画时长 15s 12s~20s 时长更长时,流光更接近环境光。
外框厚度 2px 1px~2px 建议保持克制,避免边框压过文字。

如果整体仍然偏亮,可以优先降低阴影和流光透明度:

css 复制代码
.frame {
  box-shadow: 0 24px 56px rgb(63 74 65 / 12%);
}

.frame::before {
  opacity: .72;
}

实现边界

这个组件适合用作作品集封面、品牌展示区、静态专题卡片或信息导览中的视觉内容块。它的目标是提供克制的空间反馈,而非充当万能交互容器。

当需求变为导航、筛选、提交、展开详情或切换状态时,应重新选择匹配行为的原生交互元素,并补充键盘操作、焦点状态与对应的可访问性设计,而不是直接在当前 article 容器上追加点击事件。

参考资料

相关推荐
光影少年1 小时前
react navite 长列表优化:FlatList / SectionList 原理与优化属性
前端·react native·react.js
wordbaby1 小时前
从双端内卷到一库多端:基于 pnpm + Taro 4 + RNOH 鸿蒙的 Monorepo 跨端架构实战
前端·微信小程序·app
子兮曰1 小时前
DeepSeek Harness 架构深潜:一个把 Agent 运行时做成纯插件树的开源 Harness
前端·后端·deepseek
子兮曰2 小时前
AI Agent 完整入门指南:从 LLM 到生产落地的 30+ 个核心概念
前端·后端·agent
楚宝ovo2 小时前
新增一个 demo 子应用 - 微前端
前端
JarvanMo3 小时前
Dart 3.13:小版本,大动作
前端
纯粹的热爱3 小时前
Ubuntu 安装 Node.js 22.x(通过 NodeSource 官方源)
前端
Highcharts3 小时前
如何选择正确的图表:驱动理解和洞察力的三部分框架(第3部分)
前端·数据可视化
JavaGuide3 小时前
Github 史诗级故障,与此同时,Cursor 版「GitHub」正式上线!
前端·后端