宣言逐字点亮(Text Scroll Reveal)
open-design.ai 首页 About 模块的大字宣言:整段话初始只有一层 18% 的墨影,随滚动 一个字一个字点亮,回滚就逐个熄灭 —— 亮度映射的是滚动位置而不是时间,完全跟手。 上游是 Magic UI / Inspira 的 Text Reveal,官网改成了不占高轨道的"非 pin"版。
要点
- 不钉住段落:块随页面自然上行,行程映射为
progress = (0.82·vh − rect.top) / (0.82·vh − 0.22·vh)—— 块顶从视口 82% 走到 22% 期间点亮完毕 - 第 i 个词的点亮窗口是
[i/n, (i+1)/n],窗口内线性过渡,整段就是一条被 n 等分的进度条 - 暗底是
0.18不是 0:opacity = 0.18 + 0.82·o,未点亮的字保持"墨影",段落轮廓始终可读(也顺带保住 SEO 文本) - token 化分两种粒度:CJK 字符(含全角标点)一字一个 token,拉丁字母/数字连跑攒成整词,ASCII 空格保留成独立分隔 span 防止换行粘连 —— 中英混排各按各的节奏亮
- scroll/resize 都过 rAF 节流(ticking 标志位一帧最多算一次),监听挂
{ passive:true } - JS 未接管时段落保持全墨可读:官网先加
is-reveal-active类才把控制权交给内联 opacity,prefers-reduced-motion下直接不接管 - 品牌词铺一条荧光绿底(
background: var(--coral)),是整段唯一的颜色,点亮到它时有"到站"感 - 官网另有 pin 版变体(
.about-scrolly的 sticky 轨道),同一套亮度映射换成钉住段落滚进度,取舍是:非 pin 版不吃版面高度
源码(折叠)
statement-reveal-demo.html(自包含单页,含内联 style + script)
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>宣言逐字点亮 — open-design.ai About 模块复刻</title>
<style>
/* Atelier Zero 令牌(open-design 官网 app/globals.css :root) */
:root {
--paper: #fafafa;
--paper-dark: #f0f0f0;
--ink: #262626;
--ink-soft: #434343;
--ink-mute: #595959;
--ink-faint: #8c8c8c;
--coral: #63fe13; /* 荧光绿强调色 */
--bone: #ffffff;
--line: #d9d9d9;
--shadow: 0 30px 60px -30px rgba(38, 38, 38, 0.16);
--sans: 'PingFang SC', 'Microsoft YaHei', -apple-system, sans-serif;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: var(--paper); color: var(--ink); font-family: var(--sans); }
.intro, .outro {
min-height: 88vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
text-align: center;
padding: 20px;
}
.intro h1 { font-size: clamp(24px, 4.6vw, 36px); font-weight: 800; letter-spacing: -0.02em; }
.intro h1 em { font-style: normal; background: var(--coral); padding: 0 8px; }
.intro p, .outro p { color: var(--ink-mute); font-size: 15px; max-width: 46ch; line-height: 1.8; }
.intro .down { margin-top: 20px; font-size: 22px; color: var(--ink-faint); animation: bob 1.6s ease-in-out infinite; }
@keyframes bob { 50% { transform: translateY(8px); } }
@media (prefers-reduced-motion: reduce) { .intro .down { animation: none; } }
/* ================================================================
宣言块(官网 About 模块的 .about-reveal / .about-reveal-text)。
Magic UI「Text Reveal」的思路,但官网用的是"非 pin"版:
不占一条高轨道钉住段落,而是让段落随页面自然上行,
在它从视口 82% 走到 22% 的行程里把词逐个点亮。
================================================================ */
.statement-wrap {
max-width: 820px;
margin: 0 auto;
padding: 16vh 28px;
}
.kicker {
font-size: 13px;
letter-spacing: 0.14em;
color: var(--ink-mute);
margin-bottom: 18px;
}
.statement {
font-size: clamp(24px, 4.4vw, 42px);
font-weight: 800;
letter-spacing: -0.02em;
line-height: 1.5;
}
/* 每个 token 一个 span。JS 未接管(reduced-motion / 老浏览器)时
保持全墨可读 —— 官网也是先加 is-reveal-active 类才把控制权交给
内联 opacity,动画永远只是增强。 */
.reveal-word { display: inline-block; }
.reveal-space { display: inline; }
/* 荧光绿高亮词:官网宣言里的品牌词就是这样铺一条绿底 */
.reveal-word.hl { background: var(--coral); padding: 0 6px; }
.tail-note {
margin-top: 26px;
font-size: 13px;
color: var(--ink-faint);
}
</style>
</head>
<body>
<div class="intro">
<h1>滚动是<em>调光旋钮</em></h1>
<p>下面这段宣言初始只有 18% 的墨色。往下滚 —— 每一个字随滚动进度逐个点亮,回滚则逐个熄灭,完全跟手。</p>
<span class="down">↓</span>
</div>
<div class="statement-wrap">
<p class="kicker">为什么做 Open Design?</p>
<!-- token 由 JS 在运行时切分(官网是构建期切好输出 span)。
中英混排正好验证两种切法:CJK 一字一 token、拉丁一词一 token。 -->
<h2 class="statement" id="statement"
data-text="设计不该被锁在 SaaS 画布里。让 AI 直接产出 HTML,版本进 Git,资产归你 —— Design as Code 是每个人的工作台。"
data-highlight="Design as Code"></h2>
<p class="tail-note">↑ 试试来回滚动:亮度映射的是位置,不是时间。</p>
</div>
<div class="outro">
<p>宣言走完 82% → 22% 的视口行程即全亮。这一屏只是给行程留出的跑道。</p>
</div>
<script>
(() => {
/* ================================================================
① token 化 —— 官网 page.tsx 的 tokenizeReveal:
CJK 字符(含全角标点)一字一个 token(CJK 没有词间空格,
只能按字点亮);拉丁字母/数字连跑攒成整词;ASCII 空格保留成
独立的分隔 span,这样拉丁词换行时不会粘连。
官网用的 unicode 区间正则,这里保持同一思路。
================================================================ */
const CJK = /[-ヿ㐀-鿿 -〿-]/;
const tokenize = (text) => {
const tokens = [];
let buf = '';
const flush = () => { if (buf) { tokens.push({ t: 'word', v: buf }); buf = ''; } };
for (const ch of text) {
if (ch === ' ') { flush(); tokens.push({ t: 'space' }); }
else if (CJK.test(ch)) { flush(); tokens.push({ t: 'word', v: ch }); }
else buf += ch;
}
flush();
return tokens;
};
/* 把宣言文本渲染成 span 序列;data-highlight 指定的词铺荧光绿底 */
const host = document.getElementById('statement');
const text = host.dataset.text;
const hl = (host.dataset.highlight || '').split(' ');
const frag = document.createDocumentFragment();
for (const tok of tokenize(text)) {
if (tok.t === 'space') {
const s = document.createElement('span');
s.className = 'reveal-space';
s.textContent = ' ';
frag.append(s);
} else {
const w = document.createElement('span');
w.className = 'reveal-word' + (hl.includes(tok.v) ? ' hl' : '');
w.textContent = tok.v;
frag.append(w);
}
}
host.append(frag);
const words = host.querySelectorAll('.reveal-word');
const n = words.length;
/* reduced-motion:不接管,全墨可读(官网 enhanceStatementReveal 同款兜底) */
if (matchMedia('(prefers-reduced-motion: reduce)').matches) return;
/* ================================================================
② 滚动映射 —— 官网 enhanceStatementReveal(pages/index.astro):
progress = (0.82·vh − rect.top) / (0.82·vh − 0.22·vh)
块顶在视口 82% 高度处 → progress 0(刚升入视野)
块顶到视口 22% 高度处 → progress 1(接近顶部,全亮)
第 i 个词的点亮窗口是 [i/n, (i+1)/n],窗口内线性过渡;
opacity = 0.18 + 0.82·o —— 暗底不是 0 而是 0.18,
未点亮的字保持一层"墨影",段落轮廓始终可读。
scroll/resize 都过 rAF 节流(ticking 标志位),一帧最多算一次。
================================================================ */
let ticking = false;
const update = () => {
ticking = false;
const rect = host.getBoundingClientRect();
const vh = innerHeight;
const start = vh * 0.82;
const end = vh * 0.22;
const progress = (start - rect.top) / (start - end);
for (let i = 0; i < n; i++) {
const a = i / n, b = (i + 1) / n;
let o;
if (progress <= a) o = 0;
else if (progress >= b) o = 1;
else o = (progress - a) / (b - a);
words[i].style.opacity = String(0.18 + 0.82 * o);
}
};
const onScroll = () => {
if (ticking) return;
ticking = true;
requestAnimationFrame(update);
};
addEventListener('scroll', onScroll, { passive: true });
addEventListener('resize', onScroll, { passive: true });
update();
})();
</script>
</body>
</html>