# 逐行遮罩升起（Line mask reveal）

- 俗称：文字一行一行从下面冒出来；标题一行一行升起来；文字从一条缝里升上来
- 用在：首屏标题（个人网站、作品集、品牌官网）
- 触发：进入屏幕
- 感觉：利落、高级
- 版本：1，更新于 2026-10-14

## 原理

每行文字外面套一层遮罩，文字从遮罩下方升到原位，遮罩外的部分看不见。

适合：首屏大标题、章节标题出场，让第一眼有节奏又不花哨。

## 参数

| 参数名 | 含义 | 默认值 | 建议范围 |
|---|---|---|---|
| `duration` | 每行时长 | 900ms | 300–2000ms |
| `stagger` | 行与行间隔 | 120ms | 0–600ms |
| `delay` | 第一行延迟 | 150ms | 0–1000ms |
| `distance` | 起始位移 | 110% | 100–140% |
| `easing` | 缓动 | cubic-bezier(0.22, 1, 0.36, 1) | — |

## 质量要求

- 开启"减少动态"时：不播放动画，直接显示最终状态。
- 手机上：和桌面一样；标题换行变多时，每一行照样单独升起。
- 动画只在播放时改变元素；脚本出错或没有执行时，内容照样可见（不要先用 CSS 把内容藏起来再等脚本显示）。

## 提示词（可以直接交给 AI）

```text
请在我的项目里给首屏标题加上「逐行遮罩升起」（Line mask reveal）效果：每行文字外面套一层遮罩，文字从遮罩下方升到原位，遮罩外的部分看不见。
参数：每行时长 900ms，行与行间隔 120ms，第一行延迟 150ms，起始位移 110%，缓动 cubic-bezier(0.22, 1, 0.36, 1)。
要求：保留原有的内容和布局；系统开启"减少动态"时直接显示最终状态；脚本出错或没有执行时内容仍然可见。
参考：https://donglang.linter.top/effects/line-mask-reveal.md（原理、参数和参考代码）
```

## 参考代码

HTML 结构：

```html
<!-- 标题的每一行包两层：外层是遮罩，里层是会移动的文字 -->
<h1 class="hero-title">
  <span class="lmr-line"><span>第一行文字</span></span>
  <span class="lmr-line"><span>第二行文字</span></span>
</h1>
```

CSS：

```css
/* 逐行遮罩升起：每一行是一道"缝"（遮罩），文字在缝里移动，缝外看不见。
   遮罩要加在不动的外层，不能加在正在移动的那一层上，否则遮不住。 */
.lmr-line {
  display: block;
  overflow: hidden;
  padding-bottom: 0.06em; /* 给字脚留一点地方，不被裁掉 */
}
.lmr-line > span {
  display: inline-block;
}
```

JS（原生，不依赖任何库）：

```js
// 逐行遮罩升起（Line mask reveal）· 原生 JS，不依赖任何库。
// root 是标题元素，里面每一行写成 <span class="lmr-line"><span>这一行</span></span>（配合 reveal.css）。
// 返回一个清理函数：调用它会停掉动画，文字回到最终位置。
// 开启"减少动态"时不播放，直接显示最终状态；动画用 fill: 'backwards'，脚本出错或没执行时文字照样可见。
// ignoreReducedMotion 只给"用户主动点播放"的演示用，网站自己的标题不要打开它。
export function lineMaskReveal(root, {
  duration = 900,
  stagger = 120,
  delay = 150,
  distance = 110,
  easing = 'cubic-bezier(0.22, 1, 0.36, 1)',
  ignoreReducedMotion = false,
} = {}) {
  if (!ignoreReducedMotion && matchMedia('(prefers-reduced-motion: reduce)').matches) return () => {};
  const lines = [...root.querySelectorAll('.lmr-line > span')];
  const animations = lines.map((line, i) => line.animate(
    [{ transform: `translateY(${distance}%)` }, { transform: 'none' }],
    { duration, delay: delay + i * stagger, easing, fill: 'backwards' },
  ));
  return () => animations.forEach((a) => a.cancel());
}

// 标题进入屏幕时播放一次。
// 离屏幕底边还有 40px 时就开始（碰到一点就算）：动画的第一帧是藏起来的，等露出一截再开始，会先完整露一下、再消失、再进场；
// 按露出的比例算，比屏幕还高的元素可能永远够不到比例、一直不播。root: document 让提前量按元素所在页面自己的视口算，放在 iframe 里也管用
export function revealOnEnter(root, options) {
  const io = new IntersectionObserver(([entry]) => {
    if (!entry.isIntersecting) return;
    io.disconnect();
    lineMaskReveal(root, options);
  }, { root: document, threshold: 0, rootMargin: '0px 0px 40px 0px' });
  io.observe(root);
  return () => io.disconnect();
}
```

## 出处

- 本站原创实现，参数来自本站的实验 A（逐行遮罩升起的参照实现）。
