Design System: 点击原点色块转场

一种用于站内页面切换的连续转场:用户点击链接或按钮后,纯色色块从点击位置扩张并覆盖整个视口;新页面准备好后,色块再向同一位置收缩成一点,露出新内容。

参考交互:Project Simply。本文提取的是可复用的运动语言与实现规则,不绑定原网站的品牌、页面结构或固定配色。

1. 核心体验

该转场把一次页面跳转表现为一个完整的视觉闭环:

点击 → 色点出现 → 圆形色块扩张 → 全屏遮挡 → 页面切换
→ 新页面位于色块下方 → 圆形色块收缩 → 回到点击点 → 转场结束
  • 起点明确: 动画从用户实际点击的位置发生。
  • 遮挡完整: 页面切换发生在色块完全覆盖视口之后。
  • 空间连续: 新页面中的收缩阶段沿用同一颜色、圆心比例和尺寸逻辑。
  • 节奏利落: 扩张比收缩更快,产生“进入迅速、揭示从容”的感觉。
  • 内容安全: 页面加载和 DOM 变化隐藏在全屏色块之后。

这不是普通淡入淡出,也不是一张矩形遮罩横向擦过页面。主角是一个从交互点产生、覆盖视口后又回到该点的圆。

2. 视觉结构

转场只需要一个固定覆盖层和一个圆形伪元素:

<div class="page-transition" aria-hidden="true"></div>

覆盖层

  • 固定覆盖整个视口:position: fixed; inset: 0
  • 位于所有页面内容上方。
  • 推荐 z-index: 9999 或项目统一的最高转场层级。
  • 默认透明且不接收指针事件。
  • 不添加文字、加载图标、进度条或纹理。

圆形色块

  • 使用覆盖层的 ::before 创建。
  • position: absolute
  • border-radius: 50%
  • 圆心与用户点击坐标重合。
  • 初始状态为 scale(0)
  • 完全覆盖状态为 scale(1)
  • 圆的实际直径在转场开始前动态计算,确保覆盖最远的屏幕角。

3. 点击原点

坐标来源

正常鼠标或触控点击时:

originX = event.clientX;
originY = event.clientY;

点击位置必须基于视口坐标,而不是页面滚动坐标。CSS 变量:

--transition-origin-x: 50vw;
--transition-origin-y: 50vh;

键盘与程序触发

如果链接通过键盘、辅助技术或程序触发,缺少可靠指针坐标,则回退到视口中心:

originX = viewportWidth / 2
originY = viewportHeight / 2

不要使用按钮 DOM 元素的左上角作为原点。若希望键盘触发仍与按钮建立空间关系,可以使用按钮中心点作为次级回退。

跨页保持

必须保存点击点相对于视口的比例,而不是只保存像素值:

xRatio = originX / viewportWidth;
yRatio = originY / viewportHeight;

新页面使用当前视口尺寸恢复圆心:

originX = xRatio * newViewportWidth;
originY = yRatio * newViewportHeight;

这样即使跳转期间发生窗口尺寸、浏览器栏或移动端视口变化,收缩终点仍保持合理。

4. 覆盖半径

圆必须覆盖从点击点到视口四个角中最远的一个。

const farthestX = Math.max(originX, viewportWidth - originX);
const farthestY = Math.max(originY, viewportHeight - originY);
const radius = Math.hypot(farthestX, farthestY) + safetyMargin;
  • safetyMargin 推荐 2–4px,防止亚像素边缘漏出。
  • 圆形直径为 radius * 2
  • 不使用固定的 100vw100vh,因为点击点靠近边缘时可能无法覆盖对角。
  • 可使用 80vmax 作为无脚本回退,但动态计算更可靠。

对应 CSS:

.page-transition::before {
  width: calc(var(--transition-radius, 80vmax) * 2);
  height: calc(var(--transition-radius, 80vmax) * 2);
  left: var(--transition-origin-x, 50vw);
  top: var(--transition-origin-y, 50vh);
  transform: translate3d(-50%, -50%, 0) scale(0);
}

5. 动画阶段

Phase 0:静止

  • 覆盖层 opacity: 0
  • 圆形色块 scale(0)
  • pointer-events: none
  • 页面正常可操作。

Phase 1:扩张覆盖

用户点击有效站内链接后:

  1. 阻止浏览器立即跳转。
  2. 记录目标 URL、颜色和点击原点。
  3. 计算覆盖半径。
  4. 显示覆盖层。
  5. 圆从 scale(0) 动画到 scale(1)
  6. 等待扩张动画结束。
  7. 执行页面导航。

推荐时长:

--transition-out-duration: 350ms;

推荐缓动:

cubic-bezier(0.42, 0, 0.58, 1) /* ease-in-out */

扩张必须完整结束后才能跳转。提前导航会让旧页面闪退,延迟太久则会让全屏色块产生停顿感。

Phase 2:全屏保持

  • 页面加载期间圆保持 scale(1)
  • 整个视口只显示纯色色块。
  • 保持阶段没有固定动画时长,由页面加载速度决定。
  • 不在此阶段显示 spinner,除非页面经常需要超过约 1.2s 才能响应。

Phase 3:收缩揭示

新页面初始化时:

  1. 读取上一次保存的转场状态。
  2. 恢复颜色、圆心比例与覆盖半径。
  3. 首帧强制圆处于 scale(1)
  4. 浏览器完成一次样式计算后,切换到收缩状态。
  5. 圆从 scale(1) 动画到 scale(0)
  6. 动画结束后清理状态与 CSS 变量。

推荐时长:

--transition-in-duration: 550ms;

收缩阶段比扩张阶段慢约 1.4–1.7 倍。它承担新页面的揭示过程,需要比覆盖阶段更从容。

6. 状态模型

推荐使用四个明确状态:

状态 覆盖层 圆形 scale 用途
idle 隐藏 0 页面正常状态
out 显示 0 → 1 覆盖旧页面
in-start 显示 1 新页面首帧
in-active 显示 1 → 0 揭示新页面

CSS 示例:

.page-transition {
  position: fixed;
  inset: 0;
  z-index: 9999;
  opacity: 0;
  pointer-events: none;
}

.page-transition::before {
  content: "";
  position: absolute;
  left: var(--transition-origin-x, 50vw);
  top: var(--transition-origin-y, 50vh);
  width: calc(var(--transition-radius, 80vmax) * 2);
  height: calc(var(--transition-radius, 80vmax) * 2);
  border-radius: 50%;
  background: var(--transition-color);
  transform: translate3d(-50%, -50%, 0) scale(0);
  transform-origin: 50% 50%;
  transition:
    transform var(--transition-out-duration, 350ms)
    ease-in-out;
  will-change: transform;
}

.page-transition.is-out {
  opacity: 1;
}

.page-transition.is-out::before {
  transform: translate3d(-50%, -50%, 0) scale(1);
}

.page-transition.is-in-start {
  opacity: 1;
}

.page-transition.is-in-start::before {
  transform: translate3d(-50%, -50%, 0) scale(1);
  transition: none;
}

.page-transition.is-in-active {
  opacity: 1;
}

.page-transition.is-in-active::before {
  transform: translate3d(-50%, -50%, 0) scale(0);
  transition:
    transform var(--transition-in-duration, 550ms)
    ease-in-out;
}

in-startin-active 之间必须强制浏览器提交一帧,可读取 offsetWidth 或使用双层 requestAnimationFrame。否则浏览器可能合并两次样式更新,导致收缩动画不执行。

7. 颜色系统

转场颜色应来自目标页面、目标内容或当前品牌主题,而不是随机生成。

推荐接口:

<a href="/project" data-transition-color="#4ACFED">View project</a>

触发时读取:

const color =
  link.dataset.transitionColor ||
  getComputedStyle(document.documentElement)
    .getPropertyValue("--default-transition-color");

配色关系

  • 色块必须完全不透明,避免页面切换时发生重影。
  • 颜色应与新页面建立联系,可使用目标页面的主题色或主视觉辅助色。
  • 颜色需要在全屏显示 350–900ms 时依然舒适。
  • 避免纯白色闪屏和纯黑色硬切。
  • 避免高频随机切换多种饱和色。
  • 同一内容分类可以共用一种转场色,帮助建立导航记忆。
  • 若链接没有专属颜色,使用统一品牌强调色。

参考页面使用过的颜色包括:

  • 紫色 #754AF0
  • 蓝色 #4ACFED
  • 粉色 #FFB5D6
  • 绿色 #ABF7A1
  • 黄色 #E4D515

这些值仅用于说明“链接可携带目标色”的机制,不要求其他项目照搬。

8. 导航状态传递

传统多页网站可通过 sessionStorage 保存一次性状态:

sessionStorage.setItem("transition-pending", "1");
sessionStorage.setItem("transition-color", color);
sessionStorage.setItem("transition-x-ratio", String(xRatio));
sessionStorage.setItem("transition-y-ratio", String(yRatio));

新页面读取后必须立即删除:

const pending =
  sessionStorage.getItem("transition-pending") === "1";

sessionStorage.removeItem("transition-pending");
sessionStorage.removeItem("transition-color");
sessionStorage.removeItem("transition-x-ratio");
sessionStorage.removeItem("transition-y-ratio");

SPA 或客户端路由框架不需要 sessionStorage,可以在转场控制器中保留状态:

await cover()
await router.navigate(target)
await nextPageReady()
await reveal()

禁止把转场状态长期写入 localStorage,否则刷新或未来访问时可能错误触发。

9. 链接拦截规则

只拦截符合以下条件的普通站内导航:

  • 同源链接。
  • 鼠标主键点击。
  • 没有按住 CtrlCmdShiftAlt
  • target 为空或为 _self
  • 目标 URL 与当前 URL 不同。
  • 链接未声明 data-no-transition

以下情况直接使用浏览器默认行为:

  • 外部网站。
  • mailto:tel:javascript:
  • 锚点 #section
  • 下载链接。
  • 新标签页链接。
  • 修饰键点击。
  • 浏览器前进与后退,除非项目专门实现对应转场。

转场开始后设置全局锁,防止快速重复点击触发多个导航。

10. 响应式

  • 使用 clientX/clientY 和当前视口尺寸,不依赖桌面端固定分辨率。
  • 移动端保存圆心比例,适应地址栏伸缩和方向变化。
  • 横竖屏都通过最远角公式计算半径。
  • 不把转场圆写死为 200vw,超宽屏或点击点偏移时可能不足或浪费性能。
  • 在小屏设备上可以将扩张时长缩短到 280–320ms,收缩保持 450–550ms

11. 性能规范

  • 只动画 transform,不动画 widthheighttopleft
  • 圆形尺寸在动画开始前一次性计算。
  • 使用 translate3dwill-change: transform
  • 页面内只保留一个全局转场元素。
  • 转场结束后移除状态类和临时 CSS 变量。
  • 不在圆形色块上使用模糊、阴影、噪点或复杂渐变。
  • 避免在动画期间触发同步布局读取与写入循环。

12. 可访问性与降级

减少动态效果

@media (prefers-reduced-motion: reduce) {
  .page-transition::before {
    transition-duration: 1ms !important;
  }
}

减少动态效果时仍可保留瞬时色块遮挡,但不执行明显的圆形缩放。

导航可靠性

  • 动画失败时仍必须完成导航。
  • 设置导航兜底计时器,不能依赖 transitionend 作为唯一跳转条件。
  • JavaScript 未运行时,普通 <a href> 必须仍然可用。
  • 色块仅为装饰:aria-hidden="true"
  • 转场层不获取焦点,也不向屏幕阅读器播报无意义状态。
  • 新页面显示后,焦点管理遵循项目本身的路由规范。

13. 禁用模式

  • 不从固定屏幕中心启动所有转场,除非没有点击坐标。
  • 不用矩形色块冒充该效果。
  • 不在色块上放 Logo、Loading 文案或百分比。
  • 不使用弹性回弹;覆盖边缘会短暂露出旧页面。
  • 不让色块半透明。
  • 不在圆扩张结束前跳转。
  • 不在新页面已露出后继续长时间保留覆盖层。
  • 不同时使用页面缩放、内容旋转和强烈模糊。
  • 不对外链、下载、邮箱和新标签页导航强制转场。
  • 不因动画逻辑破坏浏览器默认的修饰键点击行为。

14. 验收标准

  • 点击任意有效站内链接后,圆从实际点击点产生。
  • 圆在约 350ms 内完整覆盖四个视口角。
  • 页面切换期间看不到旧页面闪退、白屏或新页面半成品。
  • 新页面中的圆从全屏状态向原点击点收缩。
  • 收缩阶段约 550ms,视觉上比扩张阶段更从容。
  • 不同目标链接可以携带不同转场色。
  • 快速双击不会触发两次导航。
  • 外链、新标签页和修饰键点击仍遵循浏览器默认行为。
  • 窗口尺寸改变后圆仍能完整覆盖视口。
  • 降低动态效果模式下可以安全、快速地完成导航。