用 CSS 实现 3D 设备动效

2026 年 5 月,我在做公司产品 LeapAll 的 Landing Page 时,用 CSS 画了手机、笔记本和显示器。它们会随着滚动翻转、打开,屏幕里面继续播放消息和操作过程。到 7 月处理旧浏览器问题时,又补了独立的 2D 展示。

这些设备最有意思的地方,是看起来像一个有厚度的物体,实际却可以拆成很简单的平面。比如手机,主要就是屏幕和背板两层,稍微错开位置,再放进同一个 3D 空间。画面只需要让人认出设备,并看清里面的内容,不必还原一台手机的所有结构。

两层平面组成一部手机

手机的主体分成屏幕卡片与背板。屏幕卡片承载状态栏、会话和输入区,背板放摄像头与 Logo,两者使用相同的外轮廓。

两层平面错开模拟手机厚度

如果两层完全重合,旋转以后仍然像一张纸。我把背板向左下移动一点,同时放到屏幕后面:

.phone {
  position: relative;
  transform-style: preserve-3d;
}

.phone-back-plate,
.phone-frame {
  position: absolute;
  inset: 0;
  transform-style: preserve-3d;
}

.phone-back-plate {
  transform: translate3d(-3px, 1px, -5px);
}

.phone-frame {
  transform: translateZ(2px);
}

屏幕与背板在 Z 方向相隔 7px,左右、上下也有轻微偏移。转到斜侧面时,后面那层会从屏幕边缘露出来。配合灰色内侧、圆角和描边,就能让人感到机身有厚度。

这两层各自还有正反面。屏幕朝外时显示会话,翻过去后由后盖显示摄像头。反面的平面先转过 180°,再用 backface-visibility: hidden 隐藏背向观众的一面,避免透出镜像文字。

手机的结构因此更接近两张有正反面的卡片。它的体积感来自轮廓、层间距离和旋转角度共同作用,没有为每条侧边单独建立一个面。

分层组合 CSS 变换

手机除了翻面,还会略微左右摇动、前后倾斜。如果把几组动画都写到同一个元素的 transform 上,后执行的变换容易覆盖前面的效果。

这里把运动分到三层:外层负责 X 方向的倾斜,中间层负责 Y 方向的轻微摇动,内层负责完整翻面与位移。屏幕和背板都跟随内层运动。

phone-stage:整体前后倾斜
  phone-rock:左右摇动
    phone:翻面、抬起与退场
      屏幕卡片
      背板

各层保留 transform-style: preserve-3d,让里面的平面继续处在同一组空间关系里。调整摇动角度时,不需要重写翻面的关键帧;调整屏幕与背板的距离,也不会影响外层的运动方式。

倾斜的变化还用了 smoothstep,即 3p² - 2p³。当进度 p 从 0 变化到 1 时,这条曲线在两端变化慢,中间变化快。设备即将翻面和刚刚展开的时候,倾斜接近稳定;正面停留阶段再逐渐改变角度,动作更容易连起来。

用滚动进度控制关键帧

这些设备不按固定时间自行完成整段翻转。页面滚动改变一个 0 到 1 的进度值,CSS 再根据进度显示对应画面。向上滚回去时,也能回到前面的姿态。

一个做法是把动画时长设为 1 秒,保持暂停,再用负的 animation-delay 选择动画所在的位置:

.phone {
  animation: phone-rise 1s cubic-bezier(0.45, 0, 0.25, 1) paused;
  animation-delay: calc(var(--stage-progress) * -1s);
  animation-fill-mode: both;
}

@keyframes phone-rise {
  0%, 5% {
    transform: translate3d(0, 5%, -20px) rotateX(28deg) rotateY(180deg);
  }
  32%, 62% {
    transform: translate3d(0, 0, 0) rotateX(0deg) rotateY(0deg);
  }
  98%, 100% {
    transform: translate3d(0, 4%, -18px) rotateX(22deg) rotateY(-180deg);
  }
}

--stage-progress 为 0.5 时,延迟是 -0.5 秒,显示的就是动画中间位置。暂停状态让画面停在这里,后续由新的进度继续控制。

关键帧里,32% 到 62% 保持正面朝向观众。这段停留是为屏幕内容留的时间,不能只顾着让手机从一面流畅翻到另一面。屏幕里的内容也跟随进度淡入、淡出:设备尚未立起来时先不显示,正面展开后再让会话出现。

JavaScript 负责从元素的位置算进度。元素顶部刚进入视口底部时为 0,整个元素从视口上方离开时为 1,对应关系是:

整体进度 = (视口高度 - 元素顶部位置) / (视口高度 + 元素高度)

随后把需要播放设备动作的中间一段重新映射到 0 到 1。例如手机使用整体进度的 0.2 到 0.8,进入和离开附近的空间不会全部挤进翻面动作里。

滚轮可能一下跳过很大距离,所以实际写给 CSS 的进度还会通过 requestAnimationFrame 限速追赶目标值。这样快速滚动后,设备不会立即跳过中间姿态;代价是停止滚动时,动画可能还会短暂追赶。这里要在响应速度和动作完整之间选一个合适的节奏。

用顶边转轴控制笔记本开合

笔记本沿用了平面组合的方式,但动作重点变成屏幕开合。

底座和屏幕使用相同大小的矩形,初始位置重叠。它们的 transform-origin 都放在顶边中央,这条边就是铰链。整个设备先通过 rotateX(90deg) 放平,再让屏幕相对底座转动。

.laptop-base,
.laptop-lid {
  position: absolute;
  inset: 0;
  transform-origin: 50% 0;
  transform-style: preserve-3d;
}

@keyframes laptop-open {
  0%, 3%, 97%, 100% {
    transform: translate3d(0, 0, 2px) rotateX(0deg);
  }
  30%, 70% {
    transform: translate3d(0, 0, 2px) rotateX(90deg);
  }
}

屏幕从 0° 转到 90°,就完成了从合盖到打开的动作。底座稍微放低,屏幕稍微抬高,避免两者重合时出现闪烁。铰链则是一条圆角矩形,用渐变表现圆柱表面;键盘、触控板和边缘描线补上设备特征。

显示器用了更多沿 Z 方向排开的薄层来表现边缘。三种设备的复杂程度不同,但处理方式相同:先决定哪些轮廓与动作足以让人认出来,再安排平面、转轴和层间距离。

为旧浏览器提供 2D 展示

CSS 能完成这些效果,也意味着页面依赖具体的 CSS 能力。后来在一台使用 Chrome 102 的设备上,首屏出现严重抖动,笔记本和 PPT 显示器也有显示异常。

布局需要先有可用的基础值。例如高度使用 svh 时,max(54svh, 640px) 不能把第二个参数当成兼容回退:如果浏览器不认识 svh,整个表达式就可能无效。我把默认值放在 vh 上,通过 @supports 再启用 svh。

设备展示则根据所需能力选择组件。我当时不认可按 Chrome 版本号判断,因为访问网站的浏览器并不只有 Chromium。对于容器单位,除了查语法,还创建一个 100×100px 的容器,测量里面 10cqmin 的元素是否约为 10×10px。3D 属性的查询只能覆盖部分条件,复杂遮挡与合成仍需要实际查看。

能力不足时,页面直接切换到独立的 2D 组件。笔记本外壳可以去掉,屏幕里的消息、工具授权以及允许和拒绝按钮继续保留;PPT 则改为普通图片轮播。这样不用在原来的 3D DOM 上逐层关闭旋转和厚度,基础展示也更容易单独检查。

按浏览器能力选择展示组件

这次兼容修改还留下一个需要分别处理的条件:用户希望减少动态效果,与浏览器不支持 3D 不是同一件事。记录中的 2D 轮播后来改为常态播放,不能因为已经切换成 2D,就认为减少动效的偏好也得到了完整处理。

后记

这些动效所在的页面是 LeapAll 官网。

评论

评论加载中…

输入关键词开始搜索。

选择Enter 打开Esc 关闭CtrlK 唤起