这轮做了一件早就想做的事:把本站的光标换成我 Windows 桌面上那套「莉莉概念风」动画光标。听起来不过是换张指针图,做起来才发现浏览器对动画光标的支持约等于零——从「为什么 .ani 不行」一路推到「那到底怎么才能动」,素材工艺、尺寸标定、部署验证各踩了一轮坑,这篇全记下来。

1 浏览器根本不认 .ani

Windows 的动画光标是 .ani 容器:RIFF 包里装着逐帧位图加一个 rate 数组。素材本身很规矩——每个 icon chunk 去掉 8 字节 chunk 头就是一段自洽的 ICO 图像条目,Pillow 直接能解。坏在浏览器:blink 的解码器只有 bmp/gif/ico/jpeg/png/webp,new Image() 加载 .ani 当场 onerror。想搬进网页,第一步是把每一帧拆出来单独编码。

2 「转成 GIF 就能动」是个错觉

直觉方案是把帧序列打成 GIF 或 APNG 塞进 cursor: url()。实测全引擎翻车:GIF/APNG 当 cursor 时一律只画第一帧——Firefox 那条 bug 从 2013 年挂到今(492237),Chrome 行为相同。动画只对 <img> 成立,对光标属性不成立。

唯一能动的办法是让 CSS 动画自己去换 url 值:@keyframes 逐帧改 cursor: url(...),且必须 step-end——cursor 是离散动画属性,不加 step 的话规范规定整个动画区间只在关键帧中点变一次,看起来就是死图。同类生产实现有 ani-cursor(Webamp 出品),思路一致。本方案在 Chromium 实测过时间轴采样:43 帧一轮全部轮到。

@keyframes cur-wait {
  0%    { cursor: url("wait/frame-00.cur") 2 1, wait; }
  2.33% { cursor: url("wait/frame-01.cur") 2 1, wait; }
  /* …逐帧排完 43 帧… */
}
body { animation: cur-wait 2838ms step-end infinite; }

3 三个静默作废的坑

这套方案里最阴险的三类错误都不报错,只让你「什么都没发生」:

  • 纯 url() 不带兜底——CSS 规范强制 cursor: url() 后面必须逗号跟一个关键字,漏了整条声明被引擎直接丢弃,计算值回落 auto。校准页拼 CSS 时栽过,三列光标全部失效,连「按钮点不动」的假象都是它造成的;
  • 手拼 .cur 的载荷偏移忘了回填——CUR 条目的「数据长度/数据偏移」字段填 0,解码器就去文件头里找 PNG,new Image 解码失败。单条目文件的载荷必在偏移 22(6 字节头 + 16 字节条目);
  • !important 会钉死动画——important 作者声明的层叠优先级高于 CSS 动画,静态规则带 !important 时,keyframes 逐帧换 url 全部被压成首帧。要压过主题的内联光标就得上 important,要动画能动就得让位,解法是常态规则挂 html:not(.busy) 门控:进入加载态的一刻全体让位,元素不再有自己的 cursor 声明,直接继承 body 上正在推进的动画值。

还有一条不算坑但必须记住:button、input[submit] 这类控件带着 UA 自带的 cursor:default(自身声明、不继承 body),要覆盖就得逐类列全,实测漏一条就露出一块系统光标。

4 素材工艺:PNG 载荷 .cur,热点是量出来的

每一帧转成 PNG 载荷的 .cur(ICONDIR idType=2,热点写进条目的保留字段):RGBA 一像素不动、不经 GIF 量化、不做重缩放,体积还比 BMP 载荷小八成多。热点(点击落点)不是估的,是对帧的 alpha 轮廓逐像素扫出来的:箭尖 (4,2)、指尖 (5,2)、I 杠中心 (31,31)、十字中心 (32,32);缩到 32px 的条目热点同比例减半。

5 尺寸之争:64 为什么反而「太大」

素材是 64×64 的,直觉是越大越清晰。真屏验收时被打了回来:DPR 2 的屏幕上,浏览器按 CSS 像素画的 64 帧光标看起来是系统光标的两倍大。而 cursor 属性没有按 DPR 选图的机制(image-set 在 cursor 上被规范阉割,MDN 直接写明推荐 32、上限 128),64px 素材的正确用法是「缩到 32 仍然锐利」,不是物理铺满 64。

于是做了个三列并排的校准页:A=单 64 帧、B=单 32 帧、C=一个 .cur 里带 64+32 两条目赌浏览器按屏幕密度挑。真屏对比后定稿 B:32 单帧——尺寸与系统光标必然一致,清晰度靠原始 64 帧 LANCZOS 降采样保住。七个状态共 203 帧 .cur、288KB 上线,换尺寸时三套变体都在构建器里备着。

6 部署与「动画没动」的误报

挂载全走 mu-plugins:一个 PHP 挂 wp_enqueue_scripts 引 CSS/JS,CSS 负责七态光标与全部动画,JS 只在 PJAX 切页与评论提交时给 <html> 挂加载态类(事件全接完后还要留 8 秒超时兜底——事件一旦丢,不摘类就是卡在转圈的新 bug)。卸载=删三个文件加一个目录,下次加载完全还原。

验证环节差点误杀一次:在隐藏的浏览器面板里采样,busy 动画 4 秒只采到 1 帧,眼看要报「动画坏了」。查下去才明白是已知陷阱——后台页的 CSS 动画时间轴是完全冻结的,currentTime 恒定不推进。改手动 seek 动画时间轴(currentTime = duration × 比例)再读 computed cursor,0/25/50/75/99% 分别命中第 00/10/21/32/42 帧,43 帧绑定完整,动画本体没有任何问题。线上再用字节级比对收尾:抽样的每个 .cur 与本地构建产物大小逐一对齐。

收尾

现在整站箭头、手型、I 型、不可用、加载五态都是莉莉主题,切页和发评论时转圈动画自动接管;Safari 旧版本退清晰静帧,触摸设备整段关闭,减弱动效偏好下动画停下仍是自家光标。这轮的体会和上一篇一样:引擎的「没反应」大多不是拒绝,是静默作废——去读计算值、去 seek 时间轴、去比字节,每一处都有当场的答案。