跳转至

宣言逐字点亮(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>