page-transition · 过场三档(原生 API / DOM 整块 / Three 粒子字)
page-transitionv1.0.0
动效
MIT
静态站/长页需要过场:多页站翻页过渡与页内进出场(原生 View Transitions)、不想依赖新 API 的整块 DOM 位移过场、或字标要变成粒子飞入/炸散/流线汇入
不适用场景
单元素淡入上浮(一条 CSS transition 就够)、满屏 WebGL 背景(bg-fx)、生产级粒子池(fx-engines 的 fx.js)、React/Vue 路由过渡封装
WebGL 实时预览
卡片与舞台里跑的是包内真实的预览页;WebGL 类需点“运行预览”才会建立渲染上下文。
取用方式
固定版本(CDN)
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/nakamotosai/sai-assets@pack/page-transition@1.0.0/packs/page-transition/css/default.css">raw 直取(跟随 main)
curl -O https://raw.githubusercontent.com/nakamotosai/sai-assets/main/packs/page-transition/SKILL.md仓库内落地
python scripts/apply.py page-transition --to <项目目录>整包下载
https://assets.ouuut.com/downloads/page-transition.zip源码与规则
pack.json{
"schema": 1,
"id": "page-transition",
"title": "page-transition · 过场三档(原生 API / DOM 整块 / Three 粒子字)",
"kind": "fx",
"category": "web-fx",
"version": "1.0.0",
"license": "MIT",
"attribution": [
{
"name": "sai demo 存档 · view-transition-layer",
"license": "MIT",
"what": "跨文档视图过渡层 + 背景层跨页连续 + 页内卡片/弹层/懒加载进出场"
},
{
"name": "sai demo 存档 · section-text-fx",
"license": "MIT",
"what": "DOM 整块上推/散射过场与六个变体(字粒/阶梯/解模糊/描边/扫光)"
},
{
"name": "sai demo 存档 · section-text-fx-three",
"license": "MIT",
"what": "Three 粒子字过场(五形态:字板位移/粒子组字/散射重组/景深/流线汇字)"
},
{
"name": "three.js",
"url": "https://threejs.org",
"license": "MIT",
"what": "upstream/three.module.js(r170,保留 SPDX-License-Identifier 与版权头),只给 text-three 一档"
},
{
"name": "sai 组件库 · components/param-panel(v2)",
"license": "MIT",
"what": "预览页调参板本体(整份拷贝自 packs/fx-engines,同版本 sha256)"
}
],
"origin": {
"project": "saaaai-homepage",
"path": "demos/view-transition-layer",
"derived_from": "demos/section-text-fx-three"
},
"entry": "SKILL.md",
"files": {
"src": [
"src/vt-layer.css",
"src/bg-boot.js",
"src/starfield-bg.js",
"src/section-travel.js",
"src/text-three.js",
"src/lab-boot.js",
"src/param-panel.js",
"src/versions.json",
"effects/vt-layer/params.json",
"effects/section-travel/params.json",
"effects/text-three/params.json"
],
"css": [
"src/section-travel.css",
"src/text-three.css",
"src/param-panel.css",
"src/stage.css"
],
"upstream": [
"upstream/three.module.js"
],
"usage": "docs/usage.md",
"assert": "scripts/assert_tokens.py",
"preview": "preview/index.html"
},
"tags": [
"view-transitions",
"page-transition",
"cross-document",
"three",
"particle-text",
"css-only",
"reduced-motion"
],
"when": "静态站/长页需要过场:多页站翻页过渡与页内进出场(原生 View Transitions)、不想依赖新 API 的整块 DOM 位移过场、或字标要变成粒子飞入/炸散/流线汇入",
"not_for": "单元素淡入上浮(一条 CSS transition 就够)、满屏 WebGL 背景(bg-fx)、生产级粒子池(fx-engines 的 fx.js)、React/Vue 路由过渡封装",
"default_variant": null,
"verified": {
"date": "2026-11-03",
"how": "python packs/page-transition/scripts/assert_tokens.py(三档对齐+版本+sha256+three 头)+ preview/ 全部 5 页经本地 http 服务在 Chrome 打开(1440×900,控制台 0 报错)+ A→B 跨文档翻页实测",
"evidence": "PASS assert_tokens page-transition · 3 档 · param-panel 2.0.0(=fx-engines)/three r170 · sha256 12 文件 · 8 字段/8 默认值 · 45 个包内引用全命中 · 5 页控制台 0 报错 · 跨页:startViewTransition=function、翻到 B 后背景层仍在动(__bgFrames=108)、0 报错 · 本机 ego 窗口无 WebGL2,text-three 走环境闸只出可见说明(不报错、不静默半死);截图 .private/shots/page-transition-{vt-a,vt-b,section-travel,text-three}.png"
},
"canonical_home": null,
"changelog": [
"1.0.0 — 首版:vt-layer(跨页 + 页内,时长走 --vt-*)+ section-travel(六变体,applyTravelParams)+ text-three(五形态,density/glow 真正生效)+ three r170 vendor + 标准调参板"
]
}
src/section-travel.css:root {
--bg: #000;
--fg: #f2f0ea;
--muted: rgba(255,255,255,.42);
--gold: #c4a574;
--line: rgba(255,255,255,.12);
/* 默认慢:要让人看清整段位移;可用左栏速度档拧 */
--dur: 2.6s;
--ease: cubic-bezier(.33, .05, .2, 1); /* 前段不抢,中段可跟 */
--ease-out: cubic-bezier(.22, .6, .2, 1);
--travel: 78vh; /* 位移距离够长,过程才看得清 */
--fade-in: 0.55; /* 相对 --dur 的透明度进场占比 */
--fade-out: 0.45;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
height: 100%; overflow: hidden;
background: var(--bg); color: var(--fg);
font-family: "Noto Sans SC", "PingFang SC", "Segoe UI", system-ui, sans-serif;
}
#sky {
position: fixed; inset: 0; z-index: 0; pointer-events: none;
background:
radial-gradient(ellipse 80% 50% at 50% 42%, rgba(40,36,48,.5), transparent 70%),
#000;
}
#sky canvas { width: 100%; height: 100%; display: block; opacity: .85; }
/* 左栏 */
.picker {
position: fixed; z-index: 20;
top: 0; bottom: 0; left: 0;
width: min(300px, 38vw);
padding: 18px 12px 24px max(14px, env(safe-area-inset-left));
overflow-y: auto;
background: linear-gradient(90deg, rgba(0,0,0,.94) 72%, transparent);
scrollbar-width: thin;
}
.picker h1 {
font-size: 13px; font-weight: 500; letter-spacing: .14em;
text-transform: uppercase; color: var(--gold);
margin: 4px 8px 6px;
}
.picker .lead {
font-size: 12px; color: var(--muted); line-height: 1.55;
margin: 0 8px 14px; max-width: 30ch;
}
.picker .lead b { color: rgba(255,255,255,.78); font-weight: 500; }
.speed-box {
margin: 0 8px 16px;
padding: 10px 10px 12px;
border: 1px solid var(--line);
border-radius: 10px;
background: rgba(255,255,255,.03);
}
.speed-box .lbl {
font-size: 10px; letter-spacing: .16em; text-transform: uppercase;
color: var(--gold); margin-bottom: 8px;
}
.speed-box .row {
display: flex; gap: 6px; flex-wrap: wrap;
}
.speed-box button {
flex: 1; min-width: 4.2em;
font-family: inherit; font-size: 11px;
color: rgba(255,255,255,.65);
background: transparent;
border: 1px solid rgba(255,255,255,.14);
border-radius: 999px;
padding: 6px 8px; cursor: pointer;
}
.speed-box button.on {
color: #0a0806; background: var(--gold); border-color: var(--gold); font-weight: 600;
}
.speed-box .note {
margin-top: 8px; font-size: 10px; color: var(--muted); line-height: 1.4;
}
.pick {
display: block; width: 100%; text-align: left;
background: transparent; border: 1px solid transparent;
border-radius: 10px; padding: 10px 12px; margin-bottom: 6px;
color: inherit; cursor: pointer;
transition: border-color .2s, background .2s;
}
.pick:hover { border-color: rgba(255,255,255,.16); background: rgba(255,255,255,.03); }
.pick.on {
border-color: rgba(196,165,116,.55);
background: rgba(196,165,116,.08);
}
.pick .n {
font-family: ui-monospace, Consolas, monospace;
font-size: 10px; letter-spacing: .16em; color: var(--gold); margin-bottom: 4px;
}
.pick .t { font-size: 14px; font-weight: 500; margin-bottom: 3px; }
.pick .d { font-size: 11px; color: var(--muted); line-height: 1.45; }
.tools {
position: fixed; z-index: 22;
top: max(14px, env(safe-area-inset-top));
right: max(16px, env(safe-area-inset-right));
display: flex; gap: 8px;
}
.tools button, .tools a {
font-family: inherit; font-size: 11px; letter-spacing: .1em;
color: rgba(255,255,255,.7);
background: rgba(0,0,0,.55);
border: 1px solid var(--line);
border-radius: 999px; padding: 7px 14px;
cursor: pointer; text-decoration: none;
}
.tools button:hover, .tools a:hover { color: #fff; border-color: rgba(255,255,255,.35); }
.tools button.primary {
color: #0a0806; background: var(--gold); border-color: var(--gold); font-weight: 600;
}
.rail {
position: fixed; z-index: 18;
right: max(14px, env(safe-area-inset-right));
top: 50%; transform: translateY(-50%);
display: flex; flex-direction: column; gap: 14px; align-items: flex-end;
}
.rail button {
background: none; border: 0; padding: 4px 0; cursor: pointer;
color: rgba(255,255,255,.3);
font-family: ui-monospace, Consolas, monospace;
font-size: 10px; letter-spacing: .18em; text-transform: uppercase;
display: flex; align-items: center; gap: 10px;
}
.rail button .bar {
width: 2px; height: 16px; border-radius: 2px;
background: rgba(255,255,255,.18);
transition: height .35s var(--ease), background .25s;
order: 2;
}
.rail button .lbl { order: 1; opacity: 0; transition: opacity .2s; }
.rail button.on { color: rgba(255,255,255,.9); }
.rail button.on .bar { height: 36px; background: #fff; }
.rail button:hover .lbl, .rail button.on .lbl { opacity: 1; }
.prog {
position: fixed; bottom: 0; left: 0; height: 1.5px; z-index: 18;
background: linear-gradient(90deg, rgba(255,255,255,.12), #fff);
width: 33.33%;
transition: width .55s var(--ease);
}
/*
核心:整块 panel 位移过场
- 新屏从底部 (below) 推到 center
- 旧屏从 center 推到上方 (above) 离开
不是「旧淡出、新原地淡入」
*/
.stage {
position: fixed; inset: 0; z-index: 5;
display: grid; place-items: center;
pointer-events: none;
/* 父级不靠 opacity 藏内容,避免子动画被乘死 */
visibility: visible;
}
.stage .panel {
width: min(720px, 78vw);
text-align: center;
padding: 24px 16px;
will-change: transform, opacity;
/* 默认在下方待命 */
transform: translate3d(0, var(--travel), 0);
opacity: 0;
pointer-events: none;
/* 位移全程慢;透明度后半段才收,方便盯着块走 */
transition:
transform var(--dur) var(--ease),
opacity calc(var(--dur) * var(--fade-in)) var(--ease);
}
/* 当前屏:停在正中 */
.stage.is-active .panel {
transform: translate3d(0, 0, 0);
opacity: 1;
pointer-events: auto;
}
/* 向下切:旧屏缓慢往上离开(全程可见大半程) */
.stage.is-leave-up .panel {
transform: translate3d(0, calc(var(--travel) * -1), 0);
opacity: 0;
transition:
transform var(--dur) var(--ease),
opacity calc(var(--dur) * var(--fade-out)) var(--ease);
transition-delay: 0s, calc(var(--dur) * 0.35);
}
/* 向上切:旧屏缓慢往下离开(对称,不是秒切) */
.stage.is-leave-down .panel {
transform: translate3d(0, var(--travel), 0);
opacity: 0;
transition:
transform var(--dur) var(--ease),
opacity calc(var(--dur) * var(--fade-out)) var(--ease);
transition-delay: 0s, calc(var(--dur) * 0.35);
}
/* 从上方进场(往上切 / 回退):先无动画放到顶外 */
.stage.is-enter-from-above .panel {
transition: none !important;
transform: translate3d(0, calc(var(--travel) * -1), 0);
opacity: 0;
}
/* 从下方进场(往下切):先无动画放到底外 */
.stage.is-prep-below .panel {
transition: none !important;
transform: translate3d(0, var(--travel), 0);
opacity: 0;
}
.k {
font-family: ui-monospace, "JetBrains Mono", Consolas, monospace;
font-size: 11px; letter-spacing: .28em; text-transform: uppercase;
color: rgba(255,255,255,.4); margin-bottom: 14px;
}
.panel h1 {
font-family: "Noto Serif SC", "Source Han Serif SC", Georgia, serif;
font-weight: 900;
font-size: clamp(36px, 7vw, 64px);
letter-spacing: -.02em;
line-height: 1.05;
margin-bottom: 16px;
color: #f6f4ef;
}
.panel .blurb {
font-size: 15px; line-height: 1.7;
color: rgba(255,255,255,.48);
max-width: 36ch; margin: 0 auto 18px;
}
.panel .meta {
font-family: ui-monospace, Consolas, monospace;
font-size: 11px; letter-spacing: .14em;
color: rgba(196,165,116,.75);
}
.panel .list {
margin-top: 22px;
display: flex; flex-direction: column; gap: 8px; align-items: center;
}
.panel .list span {
font-family: "Noto Serif SC", Georgia, serif;
font-size: clamp(18px, 3.2vw, 28px);
font-weight: 700;
letter-spacing: -.02em;
color: transparent;
-webkit-text-stroke: 1px rgba(255,255,255,.45);
}
/* ── 在「整块上推」之上叠细节 ── */
/* A · 纯整块(无字内动画) */
[data-fx="travel"] .char { display: none; }
/* B · 整块上推 + 字粒错峰(慢:跟 --dur 走) */
[data-fx="chars"] .fx-item .char {
display: inline-block;
opacity: 0.4;
transform: translateY(0.45em);
transition:
opacity calc(var(--dur) * 0.45) var(--ease-out),
transform calc(var(--dur) * 0.55) var(--ease-out);
}
[data-fx="chars"].is-active .fx-item .char {
opacity: 1;
transform: translateY(0);
}
[data-fx="chars"].is-leave-up .fx-item .char,
[data-fx="chars"].is-leave-down .fx-item .char {
opacity: 0.25;
transition-duration: calc(var(--dur) * 0.35);
}
/* C · 整块 + 解模糊(慢) */
[data-fx="blur"] .panel {
filter: blur(12px);
transition:
transform var(--dur) var(--ease),
opacity calc(var(--dur) * var(--fade-in)) var(--ease),
filter var(--dur) var(--ease);
}
[data-fx="blur"].is-active .panel { filter: blur(0); }
[data-fx="blur"].is-leave-up .panel,
[data-fx="blur"].is-leave-down .panel { filter: blur(10px); }
/* D · 描边填色(慢) */
[data-fx="stroke"] h1 {
color: transparent;
-webkit-text-stroke: 1.2px rgba(255,255,255,.5);
transition:
color calc(var(--dur) * 0.55) var(--ease-out) calc(var(--dur) * 0.2),
-webkit-text-stroke-color calc(var(--dur) * 0.55) var(--ease-out) calc(var(--dur) * 0.2);
}
[data-fx="stroke"].is-active h1 {
color: #f6f4ef;
-webkit-text-stroke-color: transparent;
}
[data-fx="stroke"].is-leave-up h1,
[data-fx="stroke"].is-leave-down h1 {
color: transparent;
-webkit-text-stroke-color: rgba(255,255,255,.35);
transition-delay: 0s;
transition-duration: calc(var(--dur) * 0.3);
}
/* E · 行阶梯(慢) */
[data-fx="stagger"] .fx-item {
opacity: 0.55;
transform: translateY(22px);
transition:
opacity calc(var(--dur) * 0.5) var(--ease-out),
transform calc(var(--dur) * 0.55) var(--ease-out);
}
[data-fx="stagger"].is-active .fx-item {
opacity: 1;
transform: translateY(0);
}
[data-fx="stagger"].is-active .fx-item:nth-child(1) { transition-delay: calc(var(--dur) * 0.06); }
[data-fx="stagger"].is-active .fx-item:nth-child(2) { transition-delay: calc(var(--dur) * 0.14); }
[data-fx="stagger"].is-active .fx-item:nth-child(3) { transition-delay: calc(var(--dur) * 0.22); }
[data-fx="stagger"].is-active .fx-item:nth-child(4) { transition-delay: calc(var(--dur) * 0.3); }
[data-fx="stagger"].is-active .fx-item:nth-child(5) { transition-delay: calc(var(--dur) * 0.36); }
[data-fx="stagger"].is-leave-up .fx-item,
[data-fx="stagger"].is-leave-down .fx-item {
opacity: 0.35;
transition-delay: 0s !important;
transition-duration: calc(var(--dur) * 0.35);
}
/* F · 金扫(慢) */
[data-fx="shine"] h1 {
background: linear-gradient(
100deg,
#f6f4ef 0%, #f6f4ef 38%,
var(--gold) 50%,
#f6f4ef 62%, #f6f4ef 100%
);
background-size: 220% 100%;
background-position: 100% 0;
-webkit-background-clip: text;
background-clip: text;
color: transparent;
transition: background-position calc(var(--dur) * 0.9) var(--ease-out) calc(var(--dur) * 0.15);
}
[data-fx="shine"].is-active h1 { background-position: 0% 0; }
.hint-bottom {
position: fixed; z-index: 16;
left: 50%; bottom: 18px; transform: translateX(-50%);
font-size: 11px; letter-spacing: .1em;
color: rgba(255,255,255,.28);
pointer-events: none;
white-space: nowrap;
}
@media (max-width: 720px) {
.picker {
width: 100%; height: auto; max-height: 32vh;
background: linear-gradient(180deg, rgba(0,0,0,.95), rgba(0,0,0,.78));
}
.stage { align-items: end; padding-bottom: 8vh; }
.rail { display: none; }
:root { --travel: 60vh; }
}
@media (prefers-reduced-motion: reduce) {
.stage .panel {
transition: opacity .2s !important;
transform: none !important;
}
.stage.is-active .panel { opacity: 1; }
.stage:not(.is-active) .panel { opacity: 0; }
}
src/text-three.css:root {
--bg: #000;
--fg: #f2f0ea;
--muted: rgba(255,255,255,.42);
--gold: #c4a574;
--line: rgba(255,255,255,.12);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
height: 100%; overflow: hidden;
background: var(--bg); color: var(--fg);
font-family: "Noto Sans SC", "PingFang SC", "Segoe UI", system-ui, sans-serif;
}
#c {
position: fixed; inset: 0; z-index: 0;
display: block; width: 100%; height: 100%;
}
.picker {
position: fixed; z-index: 20;
top: 0; bottom: 0; left: 0;
width: min(310px, 40vw);
padding: 18px 12px 24px max(14px, env(safe-area-inset-left));
overflow-y: auto;
background: linear-gradient(90deg, rgba(0,0,0,.94) 74%, transparent);
scrollbar-width: thin;
}
.picker h1 {
font-size: 13px; font-weight: 500; letter-spacing: .14em;
text-transform: uppercase; color: var(--gold);
margin: 4px 8px 6px;
}
.picker .lead {
font-size: 12px; color: var(--muted); line-height: 1.55;
margin: 0 8px 14px; max-width: 32ch;
}
.picker .lead b { color: rgba(255,255,255,.8); font-weight: 500; }
.speed-box {
margin: 0 8px 14px; padding: 10px;
border: 1px solid var(--line); border-radius: 10px;
background: rgba(255,255,255,.03);
}
.speed-box .lbl {
font-size: 10px; letter-spacing: .16em; text-transform: uppercase;
color: var(--gold); margin-bottom: 8px;
}
.speed-box .row { display: flex; gap: 6px; flex-wrap: wrap; }
.speed-box button {
flex: 1; min-width: 4em;
font-family: inherit; font-size: 11px;
color: rgba(255,255,255,.65);
background: transparent;
border: 1px solid rgba(255,255,255,.14);
border-radius: 999px; padding: 6px 8px; cursor: pointer;
}
.speed-box button.on {
color: #0a0806; background: var(--gold); border-color: var(--gold); font-weight: 600;
}
.pick {
display: block; width: 100%; text-align: left;
background: transparent; border: 1px solid transparent;
border-radius: 10px; padding: 10px 12px; margin-bottom: 6px;
color: inherit; cursor: pointer;
}
.pick:hover { border-color: rgba(255,255,255,.16); background: rgba(255,255,255,.03); }
.pick.on {
border-color: rgba(196,165,116,.55);
background: rgba(196,165,116,.08);
}
.pick .n {
font-family: ui-monospace, Consolas, monospace;
font-size: 10px; letter-spacing: .16em; color: var(--gold); margin-bottom: 4px;
}
.pick .t { font-size: 14px; font-weight: 500; margin-bottom: 3px; }
.pick .d { font-size: 11px; color: var(--muted); line-height: 1.45; }
.tools {
position: fixed; z-index: 22;
top: max(14px, env(safe-area-inset-top));
right: max(16px, env(safe-area-inset-right));
display: flex; gap: 8px; flex-wrap: wrap; justify-content: flex-end;
}
.tools button, .tools a {
font-family: inherit; font-size: 11px; letter-spacing: .1em;
color: rgba(255,255,255,.7);
background: rgba(0,0,0,.55);
border: 1px solid var(--line);
border-radius: 999px; padding: 7px 14px;
cursor: pointer; text-decoration: none;
}
.tools button.primary {
color: #0a0806; background: var(--gold); border-color: var(--gold); font-weight: 600;
}
.rail {
position: fixed; z-index: 18;
right: max(14px, env(safe-area-inset-right));
top: 50%; transform: translateY(-50%);
display: flex; flex-direction: column; gap: 14px; align-items: flex-end;
}
.rail button {
background: none; border: 0; padding: 4px 0; cursor: pointer;
color: rgba(255,255,255,.3);
font-family: ui-monospace, Consolas, monospace;
font-size: 10px; letter-spacing: .18em; text-transform: uppercase;
display: flex; align-items: center; gap: 10px;
}
.rail button .bar {
width: 2px; height: 16px; background: rgba(255,255,255,.18);
order: 2; transition: height .35s, background .25s;
}
.rail button .lbl { order: 1; opacity: 0; }
.rail button.on { color: #fff; }
.rail button.on .bar { height: 36px; background: #fff; }
.rail button:hover .lbl, .rail button.on .lbl { opacity: 1; }
.prog {
position: fixed; bottom: 0; left: 0; height: 1.5px; z-index: 18;
background: linear-gradient(90deg, rgba(255,255,255,.12), #fff);
width: 33.33%; transition: width .55s;
}
.hint {
position: fixed; z-index: 16; left: 50%; bottom: 18px;
transform: translateX(-50%);
font-size: 11px; letter-spacing: .1em;
color: rgba(255,255,255,.28); white-space: nowrap;
pointer-events: none;
}
/* 没有 WebGL2 时 text-three.js 会插入这块可见说明(不静默失败、不留控制台噪音) */
.gl-missing {
position: fixed; z-index: 40; left: 50%; top: 50%; transform: translate(-50%, -50%);
max-width: 46ch; text-align: center; padding: 14px 18px; border-radius: 10px;
background: rgba(0, 0, 0, .88); border: 1px solid rgba(196, 165, 116, .5);
color: var(--gold, #c4a574); font-size: 13px; line-height: 1.6;
}
@media (max-width: 720px) {
.picker { width: 100%; height: auto; max-height: 34vh; bottom: auto; }
.rail { display: none; }
}
src/param-panel.css/* Sai Theme Lab · floating param panel v2
* 顶标签 / 中参数 / 底动作 · 可拖可缩放 · 无可见滚动条 · gruvbox 壳
*/
.tl-param-panel {
--tl-bg: #1d2021;
--tl-bg-2: #282828;
--tl-fg: #ebdbb2;
--tl-muted: #a89984;
--tl-accent: #fe8019;
--tl-border: #504945;
--tl-swatch-ring: #fbf1c7;
position: fixed;
z-index: 99999;
width: min(420px, calc(100vw - 24px));
height: min(72vh, 640px);
min-width: 340px;
min-height: 320px;
max-width: min(560px, calc(100vw - 16px));
max-height: min(90vh, 900px);
display: flex;
flex-direction: column;
background: var(--tl-bg);
color: var(--tl-fg);
border: 1px solid var(--tl-border);
border-radius: 12px;
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.5);
font: 14px/1.45 "Noto Sans SC", "Segoe UI", system-ui, sans-serif;
backdrop-filter: blur(12px);
user-select: none;
overflow: hidden;
}
.tl-param-panel[data-corner="br"] { right: 14px; bottom: 14px; }
.tl-param-panel[data-corner="bl"] { left: 14px; bottom: 14px; }
.tl-param-panel[data-corner="tr"] { right: 14px; top: 14px; }
.tl-param-panel[data-corner="tl"] { left: 14px; top: 14px; }
/* Wall / iframe embed: compact + default collapsed */
.tl-param-panel.is-embed {
width: min(280px, calc(100vw - 16px));
height: auto;
max-height: min(48vh, 360px);
min-width: 200px;
min-height: 0;
font-size: 13px;
box-shadow: 0 8px 22px rgba(0, 0, 0, 0.45);
}
.tl-param-panel.is-embed .tl-param-panel__resize {
display: none;
}
/* dock FAB: whole panel collapses to bottom-right square */
.tl-param-panel.is-collapsed {
width: 56px !important;
height: 56px !important;
min-width: 56px !important;
min-height: 56px !important;
max-width: 56px !important;
max-height: 56px !important;
right: 14px !important;
bottom: 14px !important;
left: auto !important;
top: auto !important;
border-radius: 14px;
padding: 0;
overflow: hidden;
cursor: pointer;
opacity: 0.96;
}
.tl-param-panel.is-collapsed:hover {
opacity: 1;
border-color: var(--tl-accent);
}
.tl-param-panel.is-collapsed .tl-param-panel__head,
.tl-param-panel.is-collapsed .tl-param-panel__tabs,
.tl-param-panel.is-collapsed .tl-param-panel__body,
.tl-param-panel.is-collapsed .tl-param-panel__foot,
.tl-param-panel.is-collapsed .tl-param-panel__resize {
display: none !important;
}
.tl-param-panel__fab {
display: none;
width: 100%;
height: 100%;
appearance: none;
border: 0;
background: linear-gradient(145deg, #3c3836, #1d2021);
color: var(--tl-accent);
cursor: pointer;
align-items: center;
justify-content: center;
padding: 0;
font: inherit;
}
.tl-param-panel.is-collapsed .tl-param-panel__fab {
display: flex;
}
.tl-param-panel__fab-mark {
font-size: 18px;
font-weight: 800;
letter-spacing: 0.04em;
color: var(--tl-fg);
}
.tl-param-panel__fab:hover .tl-param-panel__fab-mark {
color: var(--tl-accent);
}
.tl-param-panel__head {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 14px;
background: var(--tl-bg-2);
border-bottom: 1px solid var(--tl-border);
cursor: grab;
flex: 0 0 auto;
}
.tl-param-panel__head:active { cursor: grabbing; }
.tl-param-panel__title {
flex: 1;
font-weight: 700;
font-size: 15px;
letter-spacing: 0.03em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tl-param-panel__tabs {
display: flex;
flex-wrap: nowrap;
gap: 4px;
padding: 8px 10px 0;
flex: 0 0 auto;
overflow-x: auto;
scrollbar-width: none;
}
.tl-param-panel__tabs::-webkit-scrollbar { width: 0; height: 0; display: none; }
.tl-param-panel__tab {
appearance: none;
border: 1px solid transparent;
background: transparent;
color: var(--tl-muted);
border-radius: 8px 8px 0 0;
padding: 8px 12px;
font: inherit;
font-size: 14px;
font-weight: 600;
cursor: pointer;
white-space: nowrap;
}
.tl-param-panel__tab:hover {
color: var(--tl-fg);
background: color-mix(in srgb, var(--tl-bg-2) 80%, transparent);
}
.tl-param-panel__tab.is-active {
color: var(--tl-fg);
background: var(--tl-bg-2);
border-color: var(--tl-border);
border-bottom-color: var(--tl-bg-2);
}
.tl-param-panel__btn {
appearance: none;
border: 1px solid var(--tl-border);
background: transparent;
color: var(--tl-fg);
border-radius: 8px;
padding: 7px 12px;
font: inherit;
font-size: 13px;
font-weight: 600;
cursor: pointer;
line-height: 1.35;
}
.tl-param-panel__btn:hover {
border-color: var(--tl-accent);
color: var(--tl-accent);
}
.tl-param-panel__btn--accent {
border-color: color-mix(in srgb, var(--tl-accent) 55%, var(--tl-border));
background: color-mix(in srgb, var(--tl-accent) 14%, transparent);
color: var(--tl-accent);
}
.tl-param-panel__btn:disabled {
opacity: 0.4;
cursor: not-allowed;
pointer-events: none;
}
.tl-param-panel__btn--icon {
min-width: 34px;
padding-left: 8px;
padding-right: 8px;
}
.tl-param-panel__body {
flex: 1 1 auto;
min-height: 0;
overflow: auto;
padding: 12px 14px 14px;
display: flex;
flex-direction: column;
gap: 14px;
background: var(--tl-bg-2);
scrollbar-width: none;
-ms-overflow-style: none;
}
.tl-param-panel__body::-webkit-scrollbar {
width: 0;
height: 0;
display: none;
}
.tl-param-row {
display: grid;
grid-template-columns: 1fr auto;
gap: 6px 10px;
align-items: center;
}
.tl-param-row label {
grid-column: 1 / -1;
color: var(--tl-muted);
font-size: 13px;
font-weight: 600;
}
.tl-param-row input[type="range"] {
width: 100%;
accent-color: var(--tl-accent);
height: 22px;
}
.tl-param-row input[type="number"],
.tl-param-row input[type="text"],
.tl-param-row select {
width: 100%;
grid-column: 1 / -1;
background: var(--tl-bg);
border: 1px solid var(--tl-border);
color: var(--tl-fg);
border-radius: 8px;
padding: 8px 10px;
font: inherit;
font-size: 14px;
}
.tl-param-row select {
cursor: pointer;
}
.tl-param-row input[type="checkbox"] {
width: 18px;
height: 18px;
accent-color: var(--tl-accent);
justify-self: start;
}
.tl-param-val {
color: var(--tl-accent);
font-variant-numeric: tabular-nums;
min-width: 3.6em;
text-align: right;
font-size: 13px;
font-weight: 600;
}
/* gruvbox swatch grid — no free color picker */
.tl-param-swatches {
grid-column: 1 / -1;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.tl-param-swatch {
width: 28px;
height: 28px;
border-radius: 7px;
border: 2px solid color-mix(in srgb, var(--tl-border) 80%, #000);
cursor: pointer;
padding: 0;
appearance: none;
background: var(--sw, #282828);
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.25);
}
.tl-param-swatch:hover {
transform: scale(1.06);
}
.tl-param-swatch.is-active {
border-color: var(--tl-swatch-ring);
box-shadow:
0 0 0 2px color-mix(in srgb, var(--tl-accent) 70%, transparent),
inset 0 0 0 1px rgba(0, 0, 0, 0.2);
}
.tl-param-swatch-meta {
grid-column: 1 / -1;
font-size: 12px;
color: var(--tl-muted);
font-family: ui-monospace, "Cascadia Code", Consolas, monospace;
}
.tl-param-panel__foot {
flex: 0 0 auto;
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px 14px 14px;
border-top: 1px solid var(--tl-border);
background: var(--tl-bg);
}
.tl-param-panel__foot-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.tl-param-panel__foot-row .tl-param-panel__btn {
flex: 1 1 auto;
min-width: 6.5em;
text-align: center;
}
.tl-param-panel__foot-row--primary .tl-param-panel__btn {
min-height: 38px;
}
.tl-param-panel__resize {
position: absolute;
right: 2px;
bottom: 2px;
width: 18px;
height: 18px;
cursor: nwse-resize;
background:
linear-gradient(
135deg,
transparent 0 46%,
var(--tl-muted) 46% 54%,
transparent 54% 70%,
var(--tl-muted) 70% 78%,
transparent 78%
);
opacity: 0.75;
}
.tl-param-panel__resize:hover {
opacity: 1;
}
/* select + option contrast — Windows native dropdown needs explicit option colors */
.tl-param-row select,
.tl-param-board select {
background: var(--tl-bg, #1d2021) !important;
color: var(--tl-fg, #ebdbb2) !important;
color-scheme: dark;
}
.tl-param-row select option,
.tl-param-row select optgroup,
.tl-param-board select option,
.tl-param-board select optgroup {
background-color: #1d2021;
color: #ebdbb2;
}
src/stage.css/* stage.css — page-transition 预览页共用的舞台 chrome(只此一份)。
只做「页内导航 + 角标 + 提示」;三个效果的视觉各自在自己的 css 里。 */
:root { color-scheme: dark; --pt-ink: #ece7dd; --pt-dim: #8d8a83; --pt-gold: #c4a574; --pt-line: #ffffff1f; }
* { margin: 0; padding: 0; box-sizing: border-box }
.pg-nav { position: fixed; z-index: 30; left: 14px; bottom: 14px; display: flex; flex-wrap: wrap; gap: 6px;
max-width: min(70ch, calc(100vw - 40px)) }
.pg-nav a { font: 500 12px/1 "Segoe UI", "PingFang SC", system-ui, sans-serif; color: var(--pt-dim);
background: #000000b3; border: 1px solid var(--pt-line); border-radius: 999px; padding: 7px 13px;
text-decoration: none; transition: color .15s, border-color .15s }
.pg-nav a:hover { color: var(--pt-ink); border-color: #ffffff44 }
.pg-nav a.on { color: #0b0b0b; background: var(--pt-gold); border-color: var(--pt-gold); font-weight: 600 }
.pg-tip { position: fixed; z-index: 20; right: 14px; bottom: 14px; max-width: 42ch; text-align: right;
color: var(--pt-dim); font: 500 11px/1.6 ui-monospace, SFMono-Regular, Consolas, monospace; pointer-events: none }
.pg-tip code { color: var(--pt-ink) }
docs/usage.md# page-transition · 用法(过场三档:先选档,再接)
> 三档解决的是同一件事:**换屏/换页时别硬切**。但它们的能力与代价差一个量级,顺序永远是:
> **原生 API 优先 → 不行再上主包体过场 → 只有需要「粒子字」才上 Three。**
## 1 选档决策(何时用哪一档)
| 判据 | 用哪档 |
|---|---|
| 多页静态站(每次跳转都是新文档)、浏览器支持 View Transitions | **`vt-layer`**:一份 CSS 就接管翻页;再叠页内卡片进出场 / 弹层开合 / 懒加载淡入 |
| 单页内多屏切换,或目标浏览器不支持 View Transitions,或要「整块 DOM 一起位移」 | **`section-travel`**:纯 DOM + CSS,**不依赖任何新 API**;位移可双向对称 |
| 需要文字本身变成粒子(组字 / 炸散重组 / 流线汇入),且接受 three 的体积与首帧成本 | **`text-three`**:包内 vendor three r170 |
| 只要「淡入淡出」「上浮 8px」这类单元素动效 | 都不要用这三档,写一条 CSS transition 即可 |
两档可以叠加:`vt-layer` 管跨页 + `section-travel` 管页内某一段,不冲突(它们作用在不同层)。
## 2 vt-layer(原生 View Transitions API)
```html
<!-- 每一页、新旧文档都要有 -->
<link rel="stylesheet" href="./vt-layer.css">
...
<canvas id="bg" aria-hidden="true"></canvas>
<script src="./bg-boot.js" data-module="./starfield-bg.js" data-mount="mountBg" data-opacity="0.85"></script>
```
- 跨页:同源导航自动接管(`@view-transition { navigation: auto }`),**零 JS**。
- 背景层单独成组:`#bg { view-transition-name: bg }` + 新旧帧都不加动画 —— 旧帧保持可见,盖住新文档首帧的空窗;新画布不透明,一画出来就整体盖住。
- 页内卡片:JS **只改 inline `display`**(显示写 `''`、隐藏写 `'none'`),退场目标值交给 `.card[style*="display: none"]` 与 `transition-behavior: allow-discrete`,入场起点交给 `@starting-style`;重排(`appendChild` 挪 DOM)路径要临时挂 `.vt-skip`,否则整墙会重放入场。
- 可调项(预览页的调参板会写):`--vt-old-ms` / `--vt-new-ms` / `--vt-rise`。
- 预览必须经 http 服务打开:跨文档视图过渡与 ES module 都不在直接双击打开的页面上生效。
## 3 section-travel(DOM 整块上推/散射)
```html
<link rel="stylesheet" href="./section-travel.css">
<section class="stage is-active" data-i="0">…<div class="panel">…</div></section>
<script type="module" src="./section-travel.js"></script>
```
- 形状:三屏 + `go(n)`;`dir > 0`(往下切)= 新屏从底推上来、旧屏往上离开;`dir < 0` 对称反向。位移距离 `--travel`(vh)、时长 `--dur`(s)。
- 六个变体(`data-fx`):`chars`(整块 + 字粒)/ `travel`(纯整块)/ `stagger`(行阶梯)/ `blur`(解模糊)/ `stroke`(描边填色)/ `shine`(金线扫)。字粒的 delay 按 `--dur` 重算,所以调慢时字粒不会先落地。
- 锁:整段动画期间 `locked = true`,中途滚轮/按键会被忽略(打断会看到半截位移)。
- 可调项:`dur`(s)/ `travel`(vh)/ `variant`;`applyTravelParams(values)` 会把前两个写成 CSS 变量并同步内部锁表时长。
## 4 text-three(Three 粒子字)
```html
<canvas id="c"></canvas>
<script type="module" src="./text-three.js"></script> <!-- 或 import 后自行驱动 -->
```
- 五种形态:`plane`(canvas 字贴平面,整板位移)/ `glyph`(字形采样成粒子飞入)/ `scatter`(旧字炸散、新字对侧聚拢)/ `depth`(多层 Z 层叠)/ `ribbon`(沿流线汇入)。
- 字形来自 `makeTextCanvas` → `sampleCanvas`(非透明像素采样),所以换文案只是换 `SECTIONS`;**粒子数 = 采样点数 × `density`**。
- 包内 `upstream/three.module.js`(r170,MIT,保留 SPDX 头)只给这一档用;其它两档不加载它。
- 可调项:`density`(0.2–1.4)/ `glow`(点大小与不透明度);`applyTextParams(values)` 改完会重建当前形态。
- 代价:首帧要建纹理 + 采样字形;`prefers-reduced-motion` 下星空停止漂移、层叠不再呼吸。
## 5 参数 schema
- 每档一份 `effects/<id>/params.json`:`{ slug, version, defaults, schema[] }`,由标准调参板(`packs/fx-engines/src/param-panel.js` 的**同版本拷贝**)渲染。
- 允许 `range` / `number` / `select` / `checkbox` / `swatch` / `text`;`select` 的 options 支持 `{ value, label }`(`section-travel` 的变体就是这么给的)。
- 字段形状由 `packs/fx-engines/src/param-panel.schema.json` 约束。
## 6 验版本(防漂移)
- `src/param-panel.js` / `src/param-panel.css` 是 `packs/fx-engines` 的**同版本拷贝**(`PARAM_PANEL_VERSION` 2.0.0),`src/versions.json` 记 sha256;要改面板请改 fx-engines 再整份替换。
- `upstream/three.module.js` 固定 r170;升级要同时改 `src/versions.json` 的 sha256 与 `pack.json.changelog`。
- 自检:`python packs/page-transition/scripts/assert_tokens.py`(三档 实现↔参数↔预览 对齐 + 版本 + sha256 + 预览引用 + three 头完整性)。
## 7 反模式
- ✗ 在需要 `section-travel` 的场合硬上 View Transitions —— 不支持该 API 的浏览器会直接硬切,观感比没有过场更差。
- ✗ 给 `vt-layer` 的旧背景帧加 `opacity: 0` —— 过渡期间背景整段消失(新页还没初始化),看起来就是「背景重新加载」。
- ✗ 用 `appendChild` 重排卡片却不挂 `.vt-skip` —— 整墙重放入场。
- ✗ 把 `text-three` 当默认过场 —— 它要下载 three、要采样字形;能用 CSS 的场合不要上 WebGL。
- ✗ 过渡中途打断(section-travel 的 `locked`)—— 会看到半截位移。
- ✗ 三档同时套在同一次切换上 —— 叠加成双重位移。
## 8 文件
| 路径 | 作用 |
|---|---|
| `src/vt-layer.css` | 跨页 + 页内动效层(原生 View Transitions,含无障碍降级) |
| `src/bg-boot.js` | 背景层引导器(普通脚本,站内翻页立即挂,首访走 idle) |
| `src/starfield-bg.js` | 演示用零依赖星空(定种 + 墙上时钟相位,翻页接着动) |
| `src/section-travel.js` / `src/section-travel.css` | 整块位移过场 + 六个变体 |
| `src/text-three.js` / `src/text-three.css` | Three 粒子字过场(五种形态) |
| `src/lab-boot.js` | 预览页共用:params.json → 调参板 → CSS 变量/参数 |
| `src/param-panel.js` / `src/param-panel.css` | 标准调参板(与 fx-engines 同版本) |
| `src/stage.css` | 预览页共用导航/提示 chrome |
| `src/versions.json` | 版本 + sha256 + 来源 + 许可 |
| `effects/<id>/params.json` | 三份参数定义 |
| `preview/index.html` + `preview/<id>.html` | 索引 + 各档预览(含 A/B 两页跨文档演示) |
| `upstream/three.module.js` | three r170(MIT,保留 SPDX 头),只给 text-three |SKILL.md---
name: page-transition
description: Add page and section transitions to a site — cross-document View Transitions with a continuous background layer, whole-block DOM travel transitions, or Three.js particle text. Use when pages hard-cut between documents, when section scrolling needs a slow whole-block push, or when a title must dissolve into particles.
---
# page-transition
过场三档,按代价从低到高:**原生 View Transitions API → 主包体 DOM 过场 → Three 粒子字**。
先挑最省的那档,别为了效果统一就把 three 拖进静态站。
## When to use
- 多页静态站要「翻页有过渡」,且背景动画必须跨页连续(`vt-layer`)。
- 页内多屏切换要整块 DOM 位移(下切底进 / 上切顶进),且不想依赖新 API(`section-travel`)。
- 标题/字标要变成粒子飞入、炸散重组、流线汇入(`text-three`)。
- 需要卡片筛选进出场、弹层开合、懒加载媒体淡入,且不要「啪」地一跳(`vt-layer` 的页内部分)。
## When not to use
- 只要单元素淡入/上浮:写一条 CSS transition,别用这三档。
- 要的是满屏 WebGL 背景(用 `packs/bg-fx`)或生产级粒子池(用 `packs/fx-engines` 的 `fx.js`)。
- 目标浏览器既无 View Transitions 又不需要粒子字:优先 `section-travel`,不要上 three。
## Default
| 档 | 默认 | 关键量 |
|---|---|---|
| `vt-layer` | `--vt-old-ms 190` `--vt-new-ms 330` `--vt-rise 14` | 跨页零 JS |
| `section-travel` | `dur 2.6s` `travel 78vh` `variant chars` | 锁整段动画 |
| `text-three` | `density 0.8` `glow 0.6` `形态 glyph` | three r170(vendor 在包内) |
## Variants and axes
| 轴 | 取值 | 说明 |
|---|---|---|
| `section-travel` 变体 | `chars` `travel` `stagger` `blur` `stroke` `shine` | 位移之上叠的细节 |
| `text-three` 形态 | `plane` `glyph` `scatter` `depth` `ribbon` | 从「字板位移」到「流线汇字」 |
| `vt-layer` 页内 | 卡片 / 弹层 / 懒加载 | 都靠 `display` + `allow-discrete` + `@starting-style` |
| 动效偏好 | `prefers-reduced-motion` | 三档都有降级(时长压到 1ms / 停止漂移) |
## Files
| path | use |
|---|---|
| `src/vt-layer.css` | 跨页 + 页内动效层(每页 link) |
| `src/bg-boot.js` + `src/starfield-bg.js` | 背景层尽早挂载 + 跨页连续(演示用星空) |
| `src/section-travel.js` + `src/section-travel.css` | 整块位移过场(六变体) |
| `src/text-three.js` + `src/text-three.css` | Three 粒子字(五形态) |
| `src/lab-boot.js` | 预览页接线(params.json → 调参板 → CSS 变量) |
| `src/param-panel.js` `src/param-panel.css` | 标准调参板(**与 fx-engines 同版本**) |
| `src/versions.json` | 版本 + sha256(验版本) |
| `effects/<id>/params.json` | 三份参数定义 |
| `preview/index.html` + `preview/vt-a.html` `vt-b.html` `section-travel.html` `text-three.html` | 索引 + 各档预览 |
| `upstream/three.module.js` | three r170(MIT,保留 SPDX 头) |
| `docs/usage.md` | **何时用哪一档 / 接线 / 参数 / 验版本 / 反模式** |
## Agent apply steps
1. 先按 `docs/usage.md` §1 的判据选档(多数场景是 `vt-layer`,不支持时 `section-travel`,要粒子字才 `text-three`)。
2. `vt-layer`:每页 link `vt-layer.css`;背景层按 `bg-boot.js` 的三行接法放好;页内卡片只改 inline `display`,重排路径挂 `.vt-skip`。
3. `section-travel`:按 `.stage` / `.fx-item` 结构铺三屏,`import` 模块后调用 `go(n)`;调参用 `applyTravelParams(values)`。
4. `text-three`:替换 `SECTIONS` 文案;需要 `upstream/three.module.js` 同级可解析;调参用 `applyTextParams(values)`。
5. 自检:`python packs/page-transition/scripts/assert_tokens.py`;再用 http 服务打开 `preview/index.html` 与各档页,确认控制台 0 报错、A/B 两页翻页时背景不断档。
## Hard rules
- 顺序不可跳级:**能用原生 API 就不上 DOM 过场,能用 DOM 就不上 three**。
- `vt-layer` 的背景旧帧不许隐藏(`opacity: 0`),否则过渡期间背景整段消失。
- 页内卡片退场只改 inline `display`;别用 `visibility`/`height` 自创一套(会和 `@starting-style` 打架)。
- `section-travel` 动画期间必须锁定输入,禁止中途打断。
- `text-three` 的 three 版本固定在 `upstream/`(r170),升级要同步 `versions.json` 的 sha256。
- 调参板副本必须与 `packs/fx-engines` 同版本;要改面板请改 fx-engines 再整份替换。
- `preview/*.html` 的相对引用必须落在本包内;外链只用 `https://`。