智能代理群组构建动画
一个固定 Canvas 覆盖层会绘制一小队光标,当页面的每个部分滚动到视口时,这些光标会"构建"该部分。内容始终是真实的、服务端渲染的 HTML。引擎仅在短时间内隐藏元素,并为每个元素播放一小段编排动画。
在线示例:https://www.lamanhtruong.com。其源代码固定在 portfolio-anh@a5d1651。assets/swarm/ 目录包含相同的引擎,已清理干净以便复用。
何时使用,何时不使用
在页面本身就是卖点的场景下使用:例如作品集、产品发布或产品故事页面、案例研究、"关于我们的 AI" 页面。用户只会访问一次,从上到下滚动,而构建动画会向他们传递某种信息(如"这个人通过智能代理交付产品")。
对于用户日常使用的页面则跳过:例如仪表盘、文档、设置、结账、长文阅读。每次构建会花费几秒钟,而回访用户没有理由再看一遍。这就是为什么引擎会按标签页记住已完成的构建(通过 sessionStorage),并在重新加载时不再重播。
它的开销:
- 约5000行 TypeScript 代码。构建层通过
next/dynamic加载,在 hydration 之后才执行,因此首屏渲染无需等待它;平滑滚动和视差部分(Lenis、GSAP、ScrollTrigger)随页面一起加载。一个包含所有四个 living-ui 技能的全新 Next.js 15 应用,其路由 JS 约为 67 kB(首次加载 170 kB)。 - 当实时日志填充时,会有较小的布局偏移得分(在测试页面中约为 0.03,远低于 0.1 的"良好"线):新的日志行会在其固定高度的框内移动。页面其余部分不会偏移。
- 一个额外的固定 Canvas 和一个
gsap.ticker循环。当没有元素在运动时,循环会停止。 - GSAP 3.13+(免费,包括 ScrollTrigger)、
@gsap/react、Lenis。
一句话工作原理
一个微小的内联脚本放在 <head> 中,在首屏渲染之前设置 html[data-build="on"],CSS 随后将每个未构建的 [data-build-item] 通过 opacity: 0 隐藏。使用透明度而非 display 或 visibility 意味着没有布局偏移,也不会从无障碍树中移除任何内容。Hydration 后,引擎将页面扫描为每个 [data-build-section] 的任务图,为每个部分装备一个 ScrollTrigger,当某个部分进入视口时,执行 规划 → 构建 → 审查:
- 规划: 规划者绘制参考线并分发任务。
- 构建: 工作者根据距离、角色和关键路径认领任务,每个任务播放一个 GSAP 时间轴。
- 审查: 审查者勾勒出该部分,勾选检查清单并盖章。
完成的元素和部分会获得 [data-built] 属性。如果引擎从未启动,头部脚本会在 3 秒后移除该标志,从而确保没有任何内容保持隐藏。
架构、数据流以及如何扩展:references/architecture.md。
安装(Next.js App Router)
-
依赖项。 已在 Next.js 15.1 + React 18.3(工作示例)和 Next.js 15.5 + React 19.1 + Tailwind 4(一个全新的
create-next-app)上测试通过,TypeScript 5.8–5.9、[email protected]–3.15、@gsap/[email protected]、[email protected]:npm i gsap@^3.13 @gsap/react@^2.1 lenis@^1.3 -
复制引擎。 将
assets/swarm/复制到src/components/swarm/。其内部的导入路径是相对路径,因此任何文件夹都可以。 -
将标志脚本 添加到根布局的
<head>中。它会在 React 进行 hydration 之前设置<html>上的一个属性,因此请为<html>添加suppressHydrationWarning:// app/layout.tsx import { BuildFlagScript } from "@/components/swarm/build-flag-script"; export default function RootLayout({ children }: { children: React.ReactNode }) { return ( <html lang="en" suppressHydrationWarning> <head> <BuildFlagScript /> </head> <body>{children}</body> </html> ); } -
只挂载一次引擎,在使用它的页面上,放在内容之后:
// app/page.tsx(一个服务端组件;保持静态) import "@/components/swarm/swarm.css"; import { SwarmRoot } from "@/components/swarm/swarm-root"; export default function Page() { return ( <> <main>{/* 使用 data-build-* 标记的部分 */}</main> <SwarmRoot /> </> ); }SwarmRoot也会挂载由gsap.ticker驱动的 Lenis 平滑滚动。如果应用已运行 Lenis,请从swarm-root.tsx中删除<SmoothScroll />。build-layer.tsx通过useLenis()读取实例,这需要页面上某处有一个rootReactLenis 实例(<ReactLenis root />,如smooth-scroll.tsx所挂载)。它不必是父级。 -
保持路由静态。 引擎仅装饰已存在的 HTML。不要在页面的布局树中调用
cookies()、headers()或auth()来读取会话。而是在客户端获取它,这样页面可以预渲染并从 CDN 提供。在那些没有挂载SwarmRoot的页面上,标志脚本是无害的:没有元素携带data-build-item,并且故障保护机制会清除该标志。 -
设置主题。 设置 CSS 变量(
--swarm-font-mono、--swarm-accent、--swarm-text、--swarm-muted、--swarm-dim、--swarm-line、--swarm-neutral-text|fill|stroke),然后编辑palette.ts中的COLORS和ROLE_COLOR。Canvas 无法在每帧低成本地读取 CSS 变量,因此其调色板存储在 TypeScript 中。
标记页面
所有内容都由服务端渲染的标记上的数据属性驱动。完整的参考文档(包含默认值和手机行为)在 references/dom-contract.md 中。用于 Hero 区域、卡片网格、列表、图片或视频、指标条和结局的复制粘贴配方在 references/recipes.md 中。
| 属性 | 作用在 | 含义 |
|---|---|---|
data-build-section="name" | 部分包裹器 | 一个构建单元,拥有自己的规划、团队和审查 |
data-build-start="load" | section | 启动后立即构建,而不是滚动时,具有更大的 Hero 区域预算 |
data-build-item + data-build="frame|type|reveal|image|dup" | 元素 | 该类型的一个任务 |
data-build-list | 列表容器 | 其第一个项目是主项(frame);dup 兄弟项目从它复制 |
data-build-grid | 容器或 section | 布局代理首先绘制网格并框出每个单元格 |
data-build-font / -weight / -scale | type 项目 | 等宽草稿后字体切换;字重扫描;从小的开始,然后调整大小和移动 |
data-build-color="text|fill|stroke" | 任何项目 | 颜色选择器在项目出现后选取真实颜色 |
data-build-tune | frame | 打开圆角和内边距检查器 |
data-build-from="left|right|top|bottom" | image | 图片拖入的起始边缘 |
data-build-keep / ="mobile" | 任何项目 | 从不隐藏(仅在手机上使用 mobile),仍会被注释 |
data-build-label、-order、-role | 任何项目 | 标签文本、排序提示、由哪个角色处理 |
data-swarm-spawn="role" | Hero 区域中的 SVG <g> | 团队在启动时从中出现的网格节点(≥1024 px) |
data-swarm-log | 容器 | 服务端渲染的回退行会被实时日志替换 |
data-build-finale + data-deploy-target + data-deploy-text | 最后一个部分及其 CTA | 部署者在最后一次审查后"发布"页面 |
?build=off / ?build=force | URL | 为此视图禁用 / 即使此标签页已观看过也重新播放 |
引擎设置:html[data-build="on"](活动时)、[data-built](完成的项目和部分)、[data-lit](启动时的网格节点)、[data-deployed](结局目标)。
保持其正常工作的规则:
- 部分大小。 每个部分大约包含一个视口的项目。拆分长页面:时间轴中的每个工作作为一个部分,大网格中的每张卡片作为一个部分,部分标题作为一个独立部分。小部分在其预算内完成,看起来是有意为之;大部分会回退到追赶速度。
- 嵌套。 嵌套在另一个项目内的项目会等待其父项。将卡片标记为
frame,将其标题、文本和标签放在里面。 - 类型。 仅对短文本(约120个字符以内)使用
type。较长文本会自动回退到reveal。输入会读取一次字形框,因此由 React 管理的、在 hydration 后变化的文本(计数器、查看次数)应为frame,而不是type。 - LCP。 使用
data-build-keep="mobile"在手机上保持主标题可见。在手机上,Hero 标题是最大内容绘制(LCP),隐藏它会损失核心网页指标。桌面版仍可获得完整的字体切换构建。 - 实时日志。
data-swarm-log容器必须使用真实的回退行和固定高度(.swarm-log)进行服务端渲染。引擎会将其替换为实时行,并在 Skip 或卸载时恢复。
最小 Hero 示例:
<section data-build-section="hero" data-build-start="load">
<p data-build-item data-build="type">your eyebrow line</p>
<h1
data-build-item data-build="type" data-build-font data-build-weight
data-build-keep="mobile" data-build-order="-1" data-build-role="coder"
>
Your Name <span className="serif">(Accent)</span>
</h1>
<div data-build-item data-build="image" data-build-from="right" className="avatar">
<img src="/me.jpg" alt="Portrait" />
</div>
{/* next/image 也可以使用(它会渲染一个引擎可读取的内部 <img>)。 */}
<div data-build-list className="buttons">
<a data-build-item data-build="frame" href="#">GitHub</a>
<a data-build-item data-build="dup" href="#">LinkedIn</a>
<a data-build-item data-build="dup" data-build-color="fill" href="#">Resume</a>
</div>
</section>
平滑度规则
这些规则源于观察实际构建的反馈。每一条都修复了一个可见的问题。
- 绝不在截止时间强制完成。 早期版本会将每个正在运行的时间轴跳跃到 5 倍速度,并在每个部分的截止时间弹出未认领的项目。用户在每一部分的末尾都看到了一个卡顿。预算现在只是规划目标。
estimate()会缩放任务持续时间以适应计划。- 当某个部分延迟时,
pace()会平滑地提升追赶因子,在 0.7 秒内最多达到 1.7 倍。 - 即使在完全追赶的情况下,每个补间动画至少保持 120 毫秒长,并且打字步骤至少保持 16 毫秒。没有跳跃效果。
- 如果需要更快,请使光标更快(
palette.ts中的speed)。不要缩短动画。
- 只动画化 opacity、clip-path、filter 和 transform。 没有布局写入。字体切换的草稿是脱离流的布局层中的克隆,而缩放任务会在最后移除其内联 transform。
- 一个时钟。 模拟和 Canvas 绘制在单个
gsap.ticker回调中运行。React 状态仅在构建开始或停止时更改,而不是每帧更改。当没有代理在屏幕上时,ticker 会停止。 - 提前测量。 在视口下方大约两个视口范围内的部分会在
requestIdleCallback切片中测量。启动某个部分永远不会在滚动帧上导致大的布局读取突发。 - 尊重阅读者。
- 聚焦未构建部分内的任何内容会立即完成该部分,因此键盘用户永远不需要等待。
- 构建过程中的大小调整会完成正在运行的部分,因为它们的测量矩形已经过时。
- 隐藏的标签页会暂停所有内容。
- HUD 始终提供 Skip、Replay 和 Inspect。
- 手机获得更少。 每个配置文件都有自己的预算、团队和细节(
palette.ts中的detectProfile())。- 手机:8 个代理,每个部分约 2.2 秒,每页一次字体切换,每部分一次颜色选取,每个列表一次 dup 扫描,无检查器面板,无缩放任务,无部署者。
- 平板:11 个代理。
- 桌面:15 个代理,每个部分约 3.9 秒,Hero 区域 5.25 秒。
用于"更快"、"更平静"和"更多代理"的调节旋钮:references/tuning.md。
减少运动
引擎为每位访客播放,包括那些在操作系统中要求减少运动的用户。站点所有者认为构建动画是页面的重点,并且 HUD 始终提供 Skip,因此这是一个有意为之的选择,并非盲目复制的默认行为。如果要尊重该设置,请进行以下全部三项更改:
// build-flag-script.tsx:在 CODE 内部,在 d.setAttribute('data-build','on') 之前
if (matchMedia('(prefers-reduced-motion: reduce)').matches) return;
// motion.ts
export function offByUrl(): boolean {
return OFF_RE.test(window.location.search) ||
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
// smooth-scroll.tsx
respectReducedMotion: true,
两种方式都进行测试:某些浏览器(以及一些自动化设置)会报告 reduce,即使没有人选择它,这也是这里默认设置为"开启"的原因之一。
与其他效果配合使用
-
以
[data-built]为门控。 引擎在构建期间读取元素矩形并切换 opacity 和 clip-path。倾斜、闪烁、加扰、内容上的视差,或任何其他转换项目的效果,如果提前运行,都会破坏这些测量。仅当项目及其部分携带[data-built],或html[data-build]不为"on"时,才启动此类效果。一个现成的辅助函数:export function isBuilt(el: Element): boolean { if (document.documentElement.getAttribute("data-build") !== "on") return true; const item = el.closest("[data-build-item]"); if (item && !item.hasAttribute("data-built")) return false; const section = el.closest("[data-build-section]"); return !section || section.hasAttribute("data-built"); } -
视频。 包裹
<video poster>的image项目,使用其海报帧作为缩略图拖入。为包裹器提供一个data-build-label,例如clip.mp4,因为没有<img>来读取文件名。 -
装饰性视差 在仅移动装饰元素的地方可以正常使用。永远不要将其放在引擎测量的任何东西上。
使其成为你自己的
这里的引擎是一个原创实现,同样的标准适用于你从中构建的任何东西。如果一个站点启发了你,请写下是什么让该站点与众不同,并远离那些确切的元素。借鉴是可以的,但不要复制。
