从涟漪到博客:一次跨层动画的工程化落地纪实
这是一篇复盘文。我们把首页的兴趣区域(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/5display: block
。 - PortalOverlay 始终位于博客层之上,但兴趣层(Stage 1/2/3)位于 Portal 之上,以保证拉幕时看得到兴趣卡片。
- 明确阶段状态机,逐阶段切换
2. 涟漪大小“先大后小”的非单调抖动
- 症状:快速滚动或反向时,涟漪半径会出现突兀跳变。
- 根因:方向判断基于闭包旧值,且缺少去抖与单调约束。
- 方案:
- 引入
currentRippleProgressRef
保存实时半径。 - 方向检测加入
DIR_EPS
去抖;按方向做单调钳制(正向只增、反向只减)。
- 引入
3. 反向回滚时兴趣层“突然消失”或看到错误背景
- 症状:Stage 3 反向拉幕时只看得到兴趣卡片,看不到波浪和文字,或背景暴露成 Globe Section。
- 根因:拉幕期间错误地把兴趣层父容器隐藏了,或顺序上先关了兴趣层再开博客层。
- 方案:
- Stage 3 强制兴趣层
opacity: 1, visibility: visible
;博客层仍隐藏。 - Stage 4 反向优先“收涟漪”,兴趣层在文字回归时保持完整可见。
- Stage 3 强制兴趣层
4. 动画结束后的回滚出现“全黑屏”
- 症状:滚到最尾(博客动画完成)再回滚时,屏幕只剩黑幕。
- 根因:我们把“尾部停留区域”当作 ScrollTrigger 非激活来处理,导致覆盖层被整体隐藏。
- 方案:
- 在 Stage 5 内显式处理
inTailRegion
(位于ACTIVE_RANGE
之后),保持覆盖层与博客层可见,并强制时间线到完成态。 - 尾部新增淡出(见下文)。
- 在 Stage 5 内显式处理
架构定型与关键取舍
-
单通道 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
:尾部停留区间比例,影响淡出时长。
结语
动画是“感性体验 + 工程纪律”的综合体。看起来只是“一个洞”,但要面对滚动驱动的不确定性、反向回滚、层级与可见性的相互制约、边界与尾部区域的处理。我们通过状态机化、单调约束、显式可见性管理与尾部淡出,最终得到一个可靠且可维护的实现。
若你也在做跨层动画,建议从“阶段状态机”入手,把每个阶段的“谁可见、谁隐藏、谁在上面”明确写出来,再考虑时间线与曲线,最后才是打磨视觉细节。