把想法做成网站
独立开发者 · 作品集 2026
看作品Line mask reveal文字一行一行从下面冒出来
/* 逐行遮罩升起(Line mask reveal)· 原生 JS + CSS,不依赖任何库 */
/* ---- HTML 结构 ----
<!-- 标题的每一行包两层:外层是遮罩,里层是会移动的文字 -->
<h1 class="hero-title">
<span class="lmr-line"><span>第一行文字</span></span>
<span class="lmr-line"><span>第二行文字</span></span>
</h1>
*/
/* ---- CSS ---- */
/* 逐行遮罩升起:每一行是一道"缝"(遮罩),文字在缝里移动,缝外看不见。
遮罩要加在不动的外层,不能加在正在移动的那一层上,否则遮不住。 */
.lmr-line {
display: block;
overflow: hidden;
padding-bottom: 0.06em; /* 给字脚留一点地方,不被裁掉 */
}
.lmr-line > span {
display: inline-block;
}
// ---- 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();
}