SVG 的两个巧思
777 字 · 2 分钟#SVG#动效
最近做了两个小动效,都落在 SVG 上:一个手写签名的描画,一个主题切换用的泼墨。做法都不复杂,但每一处都有一个值得记下来的技巧。
签名是怎么「写」出来的
描画效果的原理是老一套:stroke-dasharray 把描边切成虚线,stroke-dashoffset 控制虚线的起点。把实线段和空隙都设成路径全长,再让 offset 从全长动画到 0,笔画就像被一笔一笔写出来。
老写法需要 JS 调 getTotalLength() 拿路径长度再塞回样式。其实 SVG 自带一个免 JS 的开关——pathLength 属性可以把任意路径的「名义长度」归一化:
<path d="M 12 62 C 18 50, ..." pathLength="1" />
path {
stroke-dasharray: 1;
stroke-dashoffset: 1;
animation: draw 1.3s cubic-bezier(0.4, 0, 0.25, 1) forwards;
}
@keyframes draw {
to {
stroke-dashoffset: 0;
}
}
声明 pathLength="1" 之后,不管真实长度是多少,dasharray 和 dashoffset 都按 1 来写,纯 CSS 搞定。
签名有好几笔,要按书写顺序错开出场。每个 path 带一个自定义属性做序号,delay 用 calc 算:
path {
animation-delay: calc(var(--stroke-index, 0) * 1.2s);
}
第二笔在第一笔快收尾时起笔,间隔比动画时长略短,笔画之间有了连着写的呼吸感。鼠标悬停时换一个同款 keyframes 重新触发,签名会当着你的面再写一遍:
泼墨不是滤镜,是烘焙
第二个动效是主题切换:一滩墨从角落炸开,盖掉旧主题。实现走的是 View Transition:新旧两张页面截图叠着,给新的那张加一个不断放大的 mask,mask 的形状就是墨渍。
墨渍的毛边第一反应是用 SVG 滤镜——feTurbulence 生成噪声,feDisplacementMap 拿噪声去扰动一个圆。试了,然后放弃:这个 mask 要在 0.8 秒内放大到 400vmax,滤镜在这种尺寸下逐帧重算,代价太大,而且放大后扰动幅度超出 filter region 会被生生裁平。
所以换了思路:毛边不在运行时算,直接烘焙进路径几何。写一个生成脚本,在极坐标下取 24 个锚点,每个锚点的半径加随机扰动,再用三次贝塞尔把相邻锚点平滑连成闭合曲线,外围撒几颗随机墨点。跑几次挑一个顺眼的,输出成 data URI 塞进 CSS:
for (let i = 0; i < 24; i++) {
const angle = (i / 24) * Math.PI * 2
const radius = BASE + (Math.random() - 0.5) * JITTER
anchors.push([50 + Math.cos(angle) * radius, 50 + Math.sin(angle) * radius])
}
// 相邻锚点之间用三次贝塞尔平滑,首尾闭合
这是烘出来的形状,静态看就是一滩墨:
让 CSS 变量学会插值
炸开的过程需要 mask 的尺寸和位置同时动画,两个属性又都依赖同一个进度值。这正是 @property 的用武之地:CSS 自定义属性默认是字符串,不能参与过渡;注册成数值类型后它自己就能被 keyframes 插值。
@property --ink-splash {
syntax: '<number>';
initial-value: 0;
inherits: false;
}
@keyframes ink-splash {
from { --ink-splash: 0; }
to { --ink-splash: 1; }
}
mask-size 和 mask-position 都写成 --ink-splash 的函数,keyframes 只动这一个变量,两个属性天然同步,缓动曲线也只用调一处。
一个现实提醒:@property 目前是 Chromium 系和 Safari 支持,Firefox 还没跟上。好在这里天然是渐进增强——不支持的浏览器里主题照样切,只是墨没有炸开的过程。
签名、泼墨,加上文章里这滩静态墨渍,全程没有一行运行时的 JS 动画——SVG 管形状,CSS 管时间。分工清楚,性能也省心。