从涟漪到博客:一次跨层动画的工程化落地纪实

记录 Cosmic Interests → Portal Overlay → Blog Section 这条跨层动画链路的多轮迭代,复盘为什么需要这么多次微调,分别解决了哪些真实问题,以及最终形成的一套稳定、可维护的实现。

September 14, 2025 (3w ago)
10 min read
1,392 views
83 likes
Category
Advanced
#gsap#scrolltrigger#nextjs#react19#ui-animation#performance

从涟漪到博客:一次跨层动画的工程化落地纪实

这是一篇复盘文。我们把首页的兴趣区域(Cosmic Interests)通过一个“涟漪开洞”的过渡,平滑衔接到博客区块(Blog Section)。看似“一个遮罩 + 一个圆洞”,但真正落地涉及跨层级可见性、滚动状态机、动画单调性、反向回滚、边界区域等一串工程问题。本文记录我们为什么需要多次调整,分别解决了哪些“用户能真实感知到”的问题,并沉淀出一套稳定策略。

相关代码:

  • src/components/CosmicInterestsSection.tsx
  • src/components/PortalOverlay.tsx
  • src/components/SnowWarpParticles.tsx
  • src/components/RippleBlogSection.tsx

背景与目标

  • 目标交互:
    • Stage 1/2/3 展示兴趣层(波浪、横向兴趣卡片、巨字排版)。
    • Stage 4 进入涟漪过渡:文案先退场→涟漪扩散→博客层逐步出现。
    • Stage 5 博客时间线播放;反向滚动时先“收涟漪”,后兴趣层回归。
  • 工程约束:
    • 使用 React 19 + Next.js 15(App Router)。
    • 滚动驱动采用 GSAP + ScrollTrigger。
    • 遮罩采用单通道 Portal:黑幕仅对自身做 CSS mask,Canvas 绘制发光环,避免裁剪内容。

为什么需要“很多次”调整

1. 层级与可见性并不是“加个 z-index”那么简单

  • 症状:兴趣卡片被过早遮住、博客层在 Stage 4 前渗出来、动画中途黑屏。
  • 根因:不同阶段需要不同的“谁在上/谁可见”,简单的固定层级无法覆盖“前后/正反滚”所有路径。
  • 方案:
    • 明确阶段状态机,逐阶段切换 display/visibility/opacity,而非单一 z-index。
    • 博客容器 blogBaseRef 在 Stage 1/2/3 强制 display: none;仅在 Stage 4/5 display: block
    • PortalOverlay 始终位于博客层之上,但兴趣层(Stage 1/2/3)位于 Portal 之上,以保证拉幕时看得到兴趣卡片。

2. 涟漪大小“先大后小”的非单调抖动

  • 症状:快速滚动或反向时,涟漪半径会出现突兀跳变。
  • 根因:方向判断基于闭包旧值,且缺少去抖与单调约束。
  • 方案:
    • 引入 currentRippleProgressRef 保存实时半径。
    • 方向检测加入 DIR_EPS 去抖;按方向做单调钳制(正向只增、反向只减)。

3. 反向回滚时兴趣层“突然消失”或看到错误背景

  • 症状:Stage 3 反向拉幕时只看得到兴趣卡片,看不到波浪和文字,或背景暴露成 Globe Section。
  • 根因:拉幕期间错误地把兴趣层父容器隐藏了,或顺序上先关了兴趣层再开博客层。
  • 方案:
    • Stage 3 强制兴趣层 opacity: 1, visibility: visible;博客层仍隐藏。
    • Stage 4 反向优先“收涟漪”,兴趣层在文字回归时保持完整可见。

4. 动画结束后的回滚出现“全黑屏”

  • 症状:滚到最尾(博客动画完成)再回滚时,屏幕只剩黑幕。
  • 根因:我们把“尾部停留区域”当作 ScrollTrigger 非激活来处理,导致覆盖层被整体隐藏。
  • 方案:
    • 在 Stage 5 内显式处理 inTailRegion(位于 ACTIVE_RANGE 之后),保持覆盖层与博客层可见,并强制时间线到完成态。
    • 尾部新增淡出(见下文)。

架构定型与关键取舍

  • 单通道 Portal(PortalOverlay

    • 黑幕只对自身做 CSS mask(圆洞),不裁剪下方博客内容;发光环用 Canvas 绘制。
    • 好处:避免多层混合裁剪导致的渲染异常;坏处:需要严格管理“谁在黑幕下、谁在黑幕上”。
  • 阶段状态机(CosmicInterestsSection.onUpdate

    • Stage 1/2/3:兴趣层 on、博客层 off、Portal off。
    • Stage 4:文案先退场→再扩散;博客层 on 但仍在洞下;兴趣横向容器隐藏以避免透洞。
    • Stage 5:博客时间线推进;尾部区域(inTailRegion)保持完成,并启用淡出。
  • 反向优先级

    • Stage 5→4:优先单调“收涟漪”;涟漪收得足够小时,文案再回归;避免突兀闪烁。

尾部淡出:解决“突然消失”的观感

  • 问题:博客动画完成后立刻切回兴趣层,视觉上太突兀。
  • 方案:在 inTailRegion 内计算 tailProgress,对博客容器做线性淡出:
    • fadeOutOpacity = 1 - tailProgress * 2(前 50% 尾部区间内完成淡出)。
    • 反向滚动时自然恢复透明度,衔接顺滑。

调试与稳定性手段

  • 精确日志(临时):输出 rawProgress/isActive/inTailRegion 等,确认代码路径。
  • 去掉父层不必要的透明度动画,避免“父子叠加”导致的黑屏。
  • 关键节点使用 display(而非纯 opacity)彻底移除点击与布局影响。
  • 移除误置的模板符 {{ }} 造成的编译报错。

性能注意

  • Canvas 绘制发光环:降采样到 min(2, devicePixelRatio)
  • 只在必要时更新遮罩半径与画布;避免在非可见阶段做重绘。
  • 移动端可考虑限制 DPR、降低粒子数量与涟漪频率。

可调参数(按手感微调)

  • DIR_EPS:方向去抖阈值(默认 1e-4)。
  • RIPPLE_STAGE4_MAX:Stage 4 最大开度(如 0.6)。
  • exitPortion:Stage 4 前段留给文案退场的占比(如 0.25)。
  • TAIL_HOLD_RATIO:尾部停留区间比例,影响淡出时长。

结语

动画是“感性体验 + 工程纪律”的综合体。看起来只是“一个洞”,但要面对滚动驱动的不确定性、反向回滚、层级与可见性的相互制约、边界与尾部区域的处理。我们通过状态机化、单调约束、显式可见性管理与尾部淡出,最终得到一个可靠且可维护的实现。

若你也在做跨层动画,建议从“阶段状态机”入手,把每个阶段的“谁可见、谁隐藏、谁在上面”明确写出来,再考虑时间线与曲线,最后才是打磨视觉细节。

CleanLove

Written by CleanLove

Front-end engineer obsessed with delightful UX and solid engineering

Initializing application
Loading page content, please wait...