2026-07-05 / 8 min

LiuliX Glaze 前端里的水墨拖尾

记录 LiuliX Glaze 从内容结构到 Canvas 水墨拖尾的实现取舍,也记下这个看似简单的效果为什么反复碰壁。

前端动效Canvas

起点

LiuliX Glaze 一开始就不只是博客壳子。我想把它做成一个能长期整理内容的地方:博客放长文,知识库放方法,项目页把同一件事相关的内容重新连起来。

前端先要解决的是内容怎么被读进去。页面要能慢慢看,也希望在进入、停留和切换时有一点呼吸感,但不能抢正文的注意力。

技术上,它现在由几条线组成:

  • Next.js App Router 负责路由、静态生成和页面组织。
  • MDX 文件负责博客、知识库、项目档案这些内容。
  • Tailwind CSS 和全局 design tokens 负责纸张质感、颜色和排版节奏。
  • Framer Motion 负责页面入场、列表重排、按钮反馈和卡片 hover。
  • Canvas 负责首页背景里的水墨拖尾和图片揭示。

最花时间的,偏偏是最像装饰的水墨拖尾。

为什么拖尾难

最初的想法很简单:鼠标经过时,背景里的荷塘图像像被墨迹擦开,指针后面留一段淡淡的尾巴。

真正开始做以后,这几个要求会互相拉扯:

  1. 它要跟手,不能有明显延迟。
  2. 它要柔软,不能像一个硬圆点跟着鼠标跑。
  3. 它要有尾巴,但尾巴不能无限变长。
  4. 它要有水墨边缘,但不能变成一圈生硬的描边。
  5. 它要在全屏运行,但不能让页面变卡。

用 DOM 堆半透明元素,第一眼也能做出拖尾,不过元素一多,布局和合成成本就会上来,尾巴还容易变成一串过于规整的圆点。

只在 canvas 上画一个跟着鼠标走的圆形 mask 也不行,效果会很像手电筒,少了墨迹散开的感觉。

最后采用的结构

现在的实现放在 InkRevealBackground 里,核心是两层 canvas:

  • imageLayer:提前把荷塘图片按 cover 方式绘制好。
  • maskLayer:只负责画水墨形状。

每一帧先画图片,再用 destination-in 把图片裁进 mask。屏幕上看到的是被墨迹揭开的图片,不是单独画出来的一团黑色。

组件挂在全站背景里,但只在满足这些条件时运行:

hover: hover
pointer: fine
prefers-reduced-motion: no-preference

所以移动端、触控设备和选择减少动效的用户都不会加载这套全屏效果。

拖尾不是一条线

最后我没有把拖尾当成一整条连续路径,而是把它拆成一组 InkTrailSegment

每个 segment 记录:

  • 起点和终点。
  • 出生时间。
  • 强度。
  • 用来生成边缘随机性的 seed。

这样每段墨迹都能按自己的生命周期消失:出现、扩散、变淡,再退出画面。尾巴不会因为碰到固定长度突然被截断。

这里很容易走极端。只保留最近 N 个点,尾巴会断;只按时间清理,高速移动时又会堆出太多段。现在两边一起限制:

  • durationMs 控制每段能活多久。
  • maxTrailSegments 控制最多保留多少段。
  • sampleIntervalPxminSegmentIntervalMs 控制采样密度。

目的不是模拟真实流体,只要让视觉上看起来连续。

水墨边缘

水墨边缘主要靠三层处理:

  1. 中心的径向渐变,保证主体柔软。
  2. 外圈的 wavelet 小波瓣,让边缘有不规则变化。
  3. 后期的 destination-out 小空洞,让墨迹散开时不那么完整。

这一段最需要克制。扰动太少像普通光斑,太多又会脏,还会盖住正文。

所以配置里保留了很多看起来很细的参数,比如:

  • headWaveletCount
  • tailWaveletCount
  • edgeJitter
  • centerAbsorbStart
  • tailMotionBloomScale

这些参数不是为了把代码写复杂。这个效果没有一条固定公式,更像调一支笔:笔锋、墨量和洇开的速度都得一点点试。

性能取舍

它跑在全屏背景里,比普通组件更容易暴露性能问题。

目前用下面几件事控制成本:

  • dprCap 限制设备像素比,避免高分屏直接把 canvas 成本翻倍。
  • maskResolutionScale 让 mask 层低分辨率绘制,再交给浏览器插值。
  • maxSamplesPerFrame 限制每帧采样,不追鼠标的每一个微小变化。
  • 空闲时不继续 requestAnimationFrame,只有 active 或还有 trail 时才继续绘制。
  • 图片层单独缓存,窗口尺寸不变时不用每帧重画图片。

原则很简单:拖尾应该像空气一样存在,不能成为页面里最贵的部分。

和 Framer Motion 的边界

按钮、卡片、列表和页面入场交给 Framer Motion 比较合适。它们属于组件级的状态变化,variants、layout 和 spring 都能直接描述。

水墨拖尾不适合硬塞进 Framer Motion。它是高频、全屏、像素级的效果,需要直接控制绘制过程。

项目里最后形成了这样的边界:

  • 界面组件的状态变化,用 Framer Motion。
  • 全屏视觉纹理和拖尾,用 Canvas。
  • 静态质感和主题,用 CSS tokens。

边界分清后,代码会好读很多。所有动效混在同一个系统里,后面只会互相拖累。

写在最后

这个拖尾最难的,不是让它动起来,而是让它动得不打扰人。

它要让页面留下记忆点,又不能把内容压住;要跟手,又不能像工具软件里的鼠标轨迹;要柔软,也不能糊成一团。

最后它成了 LiuliX Glaze 里一个很小的坚持:用代码留出一点会呼吸的空白,让页面除了文字,也有停顿。