TECH · 3 MIN READ · 1242 字

把封面做成一层会退场的舞台:sticky + 滚动进度

首页封面不再是一张静态海报:它黏在视口里,内容从下方覆盖上来,封面文字随滚动淡出、上移。实现只用了一个 sticky、几个 CSS 变量和一段 rAF。

首页封面不再是一张静态海报:它黏在视口里,内容从下方覆盖上来,封面文字随滚动淡出、上移。实现只用了一个 sticky、几个 CSS 变量和一段 rAF。

这次首页改版,最先确定下来的不是颜色,而是一个动作:向下滚动时,封面应该像舞台幕布一样退场,而不是整块被推走。

最后的实现并不复杂,但有几个容易被忽略的层级问题。

先决定谁覆盖谁

页面结构保持简单:

<main>
  <section class="hero">…</section>
  <div class="home-body">
    <div class="marquee">…</div>
    <section class="latest">…</section>
    <section class="channels">…</section>
    <section class="about">…</section>
  </div>
</main>

封面本身先正常占据一屏。滚动开始后,它通过 position: sticky 留在视口顶部;后面 .home-body 有背景和更高的层级,于是从下方盖上来。

body.page-home .hero {
  position: sticky;
  top: 0;
  z-index: 0;
  height: 100svh;
  min-height: 680px;
}

.home-body {
  position: relative;
  z-index: 2;
  background: var(--bg);
  box-shadow: 0 -26px 80px rgba(0, 0, 0, 0.42);
}

这里有三个关键点:

  1. home-body 必须有背景。 否则封面会从内容缝隙里透出来。
  2. z-index 要分开。 封面在下,内容在上;固定头部再在更上层。
  3. 祖先不能是滚动容器。 最初 body 用了 overflow-x: hidden,sticky 会受影响。改为 overflow-x: clip 后,横向仍然被裁切,但不会创建新的滚动上下文。

100svh 而不是 100vh,是为了在移动端地址栏收起/展开时,封面高度跟可视区域一致。

滚动进度:只做三件事

滚动时不改变布局,只改变三个视觉值:

const p = Math.min(1, Math.max(0, window.scrollY / (hero.offsetHeight * 0.88)));
const eased = p * p * (3 - 2 * p);

hero.style.setProperty("--cover-opacity", String(Math.max(0, 1 - eased * 1.32)));
hero.style.setProperty("--cover-shift", `${-72 * eased}px`);
hero.style.setProperty("--cover-media-opacity", String(1 - eased * 0.45));
hero.style.setProperty("--cover-scale", String(1.015 + eased * 0.045));

CSS 只负责消费变量:

.hero__inner {
  opacity: var(--cover-opacity, 1);
  transform: translate3d(0, var(--cover-shift, 0px), 0);
}

.hero__media {
  opacity: var(--cover-media-opacity, 1);
}

.hero__media img {
  transform: scale(var(--cover-scale, 1.015));
}

这样做的原因是:opacity 和 transform 不会触发重排,滚动时主要在合成层完成。文字和图片的淡出速度不同,形成一点层次,而不是整块一起消失。

实际在 1440×1000 的视口里,滚动到 420px 时测得:

  • 封面透明度约 0.385
  • 文字上移约 -33.55px
  • 图片仍然保留约 0.79 的可见度

再往下,内容完全盖住封面。

rAF 与减少运动

滚动事件本身很密集,所以只做一次 requestAnimationFrame 节流:

let ticking = false;
window.addEventListener("scroll", () => {
  if (ticking) return;
  ticking = true;
  requestAnimationFrame(() => {
    updateCover();
    ticking = false;
  });
}, { passive: true });

当 prefers-reduced-motion: reduce 时,不绑定这段滚动逻辑。封面仍然 sticky,内容仍然覆盖,但不再做位移和淡出。动画是增强,不是阅读的前提。

内容为什么只剩三块

改版前首页还有订阅横条、公告和多组入口。信息一多,覆盖滚动的节奏会被打散。

最后只保留:

  • 最新文章:一主四辅,保持编辑感。
  • 分类频道:三个入口,颜色和站点主色对应。
  • 关于我:三张视觉素材 + 简介 + GitHub / 博客园。

不是这些内容不重要,而是首页应该先回答“这里有什么”,而不是一次把所有东西都摊开。

代价

覆盖式封面不是没有成本:

  • 封面必须有一屏的固定高度,矮屏需要单独压缩字号和间距。
  • sticky 对祖先的 overflow 敏感,调试时要逐层检查。
  • 内容层必须有不透明背景,透明设计会和封面打架。
  • 如果用户用键盘快速跳转,封面仍会在背后,需要保证焦点顺序不受影响。

但换来的效果是明确的:首页第一次有了“翻页”的感觉,而不是一串纵向堆叠的区块。

好的滚动效果不是让页面动得更多,而是让内容出现得更自然。