实时动态效果
四个让页面在内容加载后持续保持动态的效果:
- 极光英雄区: 一个 WebGL 柔和光场,位于英雄区背后,会随指针弯曲。
- 全息卡片: 卡片向光标方向倾斜、抬起,并产生眩光和彩虹边缘。
- 网格聚光灯: 页面背景网格在光标周围形成一个柔和的发光圆圈。
- 故障与解密文字: 标题在进入时产生 RGB 分离效果,链接标签在悬停时进行解码。
标准是“有活力,但绝不碍事”。用户应该感受到页面对他们的反应,但任何操作都不能导致卡顿、布局偏移、劫持点击,或对屏幕阅读器隐藏文本。
在线示例:https://www.lamanhtruong.com(悬停项目卡片、在英雄区移动鼠标、悬停导航栏)。
assets/fx/ 中的文件
| 文件 | 角色 |
|---|---|
ticker.ts | 一个共享的 requestAnimationFrame 循环,当没有请求帧时进入休眠 |
built.ts | 就绪门控 (isBuilt, buildActive), frozen(), 种子化PRNG, 指针媒体查询 |
aurora.ts | WebGL 着色器,其质量缩放器和生命周期 (createAurora) |
hero-shader.tsx | <HeroShader />,极光效果的 React 包装器 |
holo.ts | 倾斜 + 眩光 + 光泽,弹性物理,注入覆盖层 |
spotlight.ts | 跟随光标的网格聚光灯(仅变换) |
glitch.ts, glitch-copies.tsx | 故障调度器, <GlitchCopies /> 和 <GlitchHeading /> |
scramble.ts, scramble-text.tsx | 解密运行器和 <ScrambleText /> 标记钩子 |
fx-root.tsx | <FxRoot />:一次性挂载全息、聚光灯、故障和解密效果 |
fx.css | 所有样式,使用 --fx-* 自定义属性进行主题化 |
该代码仅需要 React,无需动画库。模板在严格 TypeScript 下对 React 18 和 19 类型进行类型检查。
安装
- 将
assets/fx/复制到应用中,例如src/components/fx/。将文件放在一起,因为它们通过相对路径相互导入。 - 挂载它。页面本身可以保持为静态服务器组件;只有
FxRoot和HeroShader是客户端组件。
import { FxRoot } from "@/components/fx/fx-root";
import { HeroShader } from "@/components/fx/hero-shader";
import { GlitchHeading } from "@/components/fx/glitch-copies";
import { ScrambleText } from "@/components/fx/scramble-text";
export default function Page() {
return (
<div className="fx-grid"> {/* 固定基础网格;如果你有自己的网格可以跳过 */}
<main className="relative z-[1]"> {/* 内容位于网格+聚光灯之上 */}
<section className="relative min-h-[80vh]"> {/* 英雄区必须是 position: relative */}
<HeroShader /> {/* 第一个子元素,位于英雄内容之后 */}
<h1>…</h1>
</section>
<GlitchHeading as="h2" className="text-5xl font-semibold">精选作品</GlitchHeading>
<article data-holo className="rounded-2xl border p-6">
<a href="/work/x" className="inline-flex items-center gap-2">
<ScrambleText>阅读案例研究</ScrambleText> <ArrowIcon />
</a>
</article>
</main>
<FxRoot /> {/* 每页一次,位于网格包装器内 */}
</div>
);
}
- 主题化:编辑
fx.css顶部的--fx-*变量,并将palette/intensity传递给<HeroShader />(请参阅下面的“主题化”)。 - 在完成之前,使用
references/verification.md中的 Playwright 检查进行验证。
通过 props 关闭部分效果:<FxRoot holo={false} spotlight={false} />。将倾斜指向现有卡片,无需触及它们的标记:<FxRoot holo={{ selector: ".card", within: "main" }} />。
保持流畅的规则
这些规则是区分“高级感”和“卡顿感”的关键。每一条的存在都是因为违反它会导致可见问题。
-
等待内容就绪。 切勿倾斜、故障或解密另一个脚本仍在显示或测量的内容。在元素显示过程中对其应用变换会破坏正在动画化该元素的任何人的测量结果,结果看起来像卡顿。
built.ts实现了一个约定:当设置了html[data-build="on"]时,一个元素只有在它的[data-build-item]和[data-build-section]携带[data-built]时才被计数。如果页面上没有构建引擎,所有内容都会立即被视为已构建。任何入场动画都可以通过设置这些属性来选择加入。详细信息:references/gating-and-performance.md。 -
使用一个帧循环,并让它休眠。 每个效果都订阅
ticker.ts,而不是自己调用requestAnimationFrame。当订阅者稳定时返回false,当没有人需要帧时循环停止。这样一来,空闲页面不消耗任何资源,并且效果之间不会争抢帧。故障效果完全不使用帧:它是由属性触发的 CSS 关键帧。 -
仅动画化
transform、opacity和clip-path。 这些保留在合成器上。这里没有任何内容会改变大小、在流中的位置或字体度量,因此布局偏移自然为零。解密效果将真实文本保留在原位(透明)并在覆盖层中绘制字形,因此即使不断变化的标签也永远不会改变宽度。 -
将效果与输入匹配。 倾斜和跟随聚光灯仅在
(hover: hover) and (pointer: fine)时存在。在触摸设备上,手指下的倾斜会被视为错误,因此触摸会在触摸点获得短暂的淡出效果和自漂移着色器镜片。 -
在静止时,不干扰 React 的 DOM。 全息覆盖层
<span>和内联transform在卡片被激活时添加,在卡片恢复稳定后移除。在静止状态下,DOM 与 React 渲染的内容完全一致,因此重新渲染、水合和卡片自身的:hover样式保持正常工作。故障层和解密跨度是在标记中编写的(<GlitchCopies>、<ScrambleText>),而不是在运行时插入到 React 拥有的节点周围;从外部重构 React 的树会破坏协调。 -
保持文本真实。 故障副本是仅客户端且
aria-hidden的,因此静态 HTML、页面内查找和屏幕阅读器每个标题都只看到一个。解密覆盖层是aria-hidden的,并且真实标签在整个过程中始终保持可访问名称。 -
清理并生存于 StrictMode。 每个
setup*都会返回一个释放器,用于移除监听器、观察器、覆盖层和内联样式。着色器会释放其缓冲区和程序,但故意不调用WEBGL_lose_context,因为 React StrictMode 在同一画布上挂载、卸载和重新挂载,而丢失的上下文将保持丢失状态。 -
预算 GPU。 着色器在像素预算下以降低的分辨率渲染,仅在可见时绘制,在高刷新率屏幕上将绘制速率限制在每秒约80帧,并且在页面运行低于约38 fps时降级一次质量。它拒绝在软件回退(
failIfMajorPerformanceCaveat)上启动,并且宁愿渲染为空也不愿渲染缓慢的内容。
效果
极光英雄区着色器 (aurora.ts, hero-shader.tsx)
着色器是域扭曲值噪声(被 fbm 扭曲的 fbm),绘制为沿扭曲轮廓的宽泛发光加上细亮的条纹。指针充当透镜:它会放大并扭曲附近的光流。进入英雄区、点击或快速移动会产生柔和的波纹环。输出是预乘 alpha,并在每个边缘淡出,以便页面背景和任何固定网格能够透出,并且英雄区与下一部分自然融合,而不是在一个盒子里结束。
生命周期:画布在其部分构建完成后在 1.8 秒内淡入。它仅在部分可见(使用 80 像素边距的 IntersectionObserver)且标签页可见时渲染。如果 WebGL 缺失,则渲染为空。
可调参数:
palette:{ veil, primary, secondary, accent }作为十六进制值。调色板会被编译到着色器中,因此更改需要重新挂载。intensity:倍增光线。0.6 表示平静,1.4 表示强烈。- 文本可读性:着色器将光线偏向远离左下角,英雄文本通常位于此处。如果你的文本位于别处,请更改
bias行(请参阅参考)。
在更改着色器数学、速度或质量缩放器之前,请阅读 references/aurora-shader.md。
全息卡片 (holo.ts)
委托的 pointerover 在精细指针下找到卡片。激活时,它会测量卡片未变换的盒子,注入一个 aria-hidden 覆盖跨度,并运行一个临界阻尼弹簧(以 120 Hz 子步进,因此在 60 或 144 fps 下行为相同)朝向指向指针的倾斜。光标下方的边缘朝向观看者。离开时,它会以柔和的过冲弹回,并在稳定后移除所有内容。
- 倾斜度随卡片大小缩放,2.5° 到 7.5°:大卡片倾斜较少,以免其远边缘摆动。透视是较长边的 1.6 倍。抬起高度为 3 px。
- 覆盖层在指针处具有径向眩光、微弱的对角线色彩水洗,以及一个遮罩到边框的圆锥渐变环(四周暗淡,指针附近明亮)。
- 在
overflow: hidden卡片上,1 px 的边框盒被裁剪,因此环绘制在内部(.fx-holo--in,自动检测)。 - 覆盖层位于
z-index: 3,并带有pointer-events: none,因此它会绘制在卡片内部的控件(视频切换、链接)之上,而不会阻止点击。 - 静态定位的卡片仅在激活时获得
position: relative。 - 选择退出:卡片或任何祖先上的
data-holo="off"。当某些内容读取真实矩形(检查器、屏幕截图)时,使用html[data-fx-freeze]暂停所有倾斜。living-ui-swarm检查切换会自动检测。
网格聚光灯 (spotlight.ts, div.fx-spot)
一个 560 px 的固定窗口,径向遮罩,包含基础网格的更亮副本。每帧仅移动两个变换:窗口跟随光标,内部的网格通过 floor(x / cell) * cell - x 移回,使其线条精确落在基础网格的格点上。两个网格都必须锚定在视口原点,并且 .fx-grid::before 正是如此。跟踪 lerp 稳定,并且在光标停止时循环休眠。在窗口模糊或指针离开窗口时隐藏。触摸在触摸点获得 360 ms 的淡出效果。
网格单元格、细线间距和窗口大小来自 --fx-grid-cell、--fx-grid-fine 和 --fx-spot-size(px 值),因此 CSS 保持为唯一真实来源。
故障标题 (glitch.ts, <GlitchHeading> / <GlitchCopies>)
标记:一个 .fx-glitch[data-fx-glitch] 包装器,包含真实标题 (.fx-glitch__base) 和两个仅客户端副本。GlitchHeading 编写所有内容;当标题已经存在并携带其他属性时,直接使用 GlitchCopies。
- 入场: 当构建好的标题至少 50% 可见时,持续 560 ms。同一标题在 2.5 秒内不会重播。
- 微故障: 每 8-14 秒在一个随机可见标题上持续 260 ms。
- 随机性是种子化的,因此节奏在每次访问和测试中都是相同的。
- 一次只有一个标题发生故障;入场效果彼此排队。
- 通过由
[data-glitch]切换的纯 CSS 关键帧实现:使用clip-path: inset()切片,使用translate3d偏移,并使用text-shadow拆分颜色。 --fx-gi每次运行时缩放偏移量。
文字解密 (scramble.ts, <ScrambleText>)
仅包装标签,而不是图标:<a><Icon/> <ScrambleText>{label}</ScrambleText></a>。
- 触发条件: 悬停(非触摸)或封闭的
a、button或[data-fx-scr-host]上的键盘:focus-visible。 - 持续时间: 360-520 毫秒,取决于长度。
- 解析: 字符从左到右解析,略带抖动。未解析的尾部以约 18 Hz 的速度重绘,而不是每帧,因此它读起来像是解码,而不是噪声。
- 字形池: 大小写匹配,并且像
@ . / : -这样的标点符号保持不变,因此电子邮件和路径在解密过程中保持可读。 - 清理: 覆盖层始终被清空,包括为背景标签页设置的计时器回退,因为 rAF 会停止。
使用 inline 处理纯内联链接(保留下划线)。使用 quiet 处理填充按钮,并结合按钮上的 --fx-scr-tail: currentColor。
主题化
| 变量 | 使用者 | 默认值 |
|---|---|---|
--fx-accent | 故障层 A, 解密字形, 聚光灯发光, 全息阴影 | #5eead4 |
--fx-hot | 故障层 B | #f472b6 |
--fx-sheen-1..5 | 全息环,按顺序围绕圆环 | 青色, 蓝色, 紫色, 粉色, 琥珀色 |
--fx-grid-line, --fx-grid-fine-line | 基础网格线 | 低透明度石板色 |
--fx-grid-cell, --fx-grid-fine | 网格间距 (px) | 96px, 24px |
--fx-spot-size | 聚光灯窗口 (px) | 560px |
--fx-mono | 解密字形字体 | 系统等宽字体 |
--fx-scr-tail | 每个元素的字形颜色覆盖 | 未设置 |
颜色通过 color-mix(in srgb, …) 混合,所有当前浏览器均支持。着色器使用自己的调色板:<HeroShader palette={{ accent: "#22d3ee", secondary: "#f0abfc" }} intensity={0.8} />。
从与页面强调色相同的系列中选择着色器颜色。着色器是一种氛围,而不是第二种品牌颜色;如果首次访问的用户在注意到标题之前就注意到了着色器,那就太刺眼了。
层级
| 层 | z-index | 备注 |
|---|---|---|
基础网格 (.fx-grid::before, fixed) | 0 | 锚定在视口原点 |
聚光灯 (div.fx-spot, fixed) | 0 | 在树顺序中位于网格之后,因此绘制在网格之上 |
内容 (main, footer) | 1 | 必须使用 position: relative; z-index: 1 定位 |
英雄区着色器 (canvas.fx-shader) | auto | 绝对定位在英雄区内部,作为第一个子元素,因此后续内容绘制在其之上 |
| 全息覆盖层 | 卡片内部为 3 | pointer-events: none |
| 固定头部, 任何构建动画覆盖层画布 | 内容之上 | 不受影响 |
如果聚光灯显示在文本上方,则内容包装器缺少 position: relative; z-index: 1。
运动偏好
在 lamanhtruong.com 上,所有者选择为每个人播放所有效果,包括 prefers-reduced-motion 的访客,因为动态效果是作品集的核心。这是一个合法的产品决策,但应该是有意为之。要改为尊重该设置:
- `<FxRoot motion="r
…