全站前端动效 + 交互栈:从舞台拉帘到粘性光标
一口气给我的 Hugo 个人站装了整套前端动效,从"开幕效果"到"粘性光标"到"乱码解码"再到"纯前端搜索",整整跑了一下午。中间踩了不少 hugo --minify 和 layer stacking 的坑,整理成这篇笔记。
最终效果一览
| 动效 | 位置 | 原理 |
|---|---|---|
| 舞台拉帘 | 全站入场 | 两层遮罩同时向相反方向滑走(curtain-up / curtain-down) |
| 终端打字 + C 高亮 | Hero 终端 | textContent 写骨架避 minify + appendChild(createTextNode) 逐字追加 |
| 乱码解码 | Hero 三行文字 | 三态:opacity:0 → 每帧换乱码 → 锁定真实字符 |
| 磁吸元素 | CTA / 卡片 / chip | 线性 falloff:(1 - dist/radius) * strength 衰减 |
| 粘性光标 + shape morph | 全站 | 双层 blob(dot + ring),hover 时 ring morph 成元素轮廓 |
| 纯前端搜索 | /notes/ | 首页 JSON 输出 fetch("/index.json") + 关键词 AND 匹配 |
| JetBrains Mono 自托管 | 全站 | Google Fonts 国内不稳,woff2 直接丢 static/fonts/ |
| RSS + Twitter Card | head.html | 一处计算、三处复用 meta;Twitter Card 改 summary_large_image |
一、舞台拉帘 Curtain Reveal
效果:页面加载后,两层深色遮罩同时向相反方向拉开——外层从底部向上滑走,内层从顶部向下滑走,像舞台剧开幕。
CSS 实现:position: fixed; inset: 0; z-index: 9999 的父容器,两个各占半屏的子 div。
.curtain-wrap .curtain-outer { /* 下半屏 */
bottom: 0;
animation: curtainUp 1.4s cubic-bezier(0.76, 0, 0.24, 1) forwards;
}
.curtain-wrap .curtain-inner { /* 上半屏,稍慢 80ms 错落 */
top: 0;
animation: curtainDown 1.4s cubic-bezier(0.76, 0, 0.24, 1) 0.08s forwards;
}
@keyframes curtainUp { from { transform: translateY(0); } to { transform: translateY(100%); } }
@keyframes curtainDown { from { transform: translateY(0); } to { transform: translateY(-100%); } }
第一个坑:父容器 curtain-wrap 写了 background: #0a0e1a 做 FOUC 兜底,结果帘子滑走了父元素的纯色背景永远盖在全屏上。根因:父元素不应该有 background,兜底色移到两个子 div 的内联 style 里。
第二个坑:想做 curtainAutoNone keyframe 让父元素在 1.5s 后切 pointer-events: none,但 pointer-events 不是可动画 CSS 属性,整个 keyframe 是空炮。修法:直接给 .curtain-wrap { pointer-events: none },子元素滑走后父就是透明空壳。
prefers-reduced-motion 降级:直接跳过动画,帘子初始不可见:
.curtain-wrap .curtain-outer { animation: none; transform: translateY(100%); }
.curtain-wrap .curtain-inner { animation: none; transform: translateY(-100%); }
二、终端打字 + C 语法高亮
效果:Hero 区一个可编辑终端,加载后逐字符敲出 printf("Helloworld!\n");,printf(" 后有 0.5s 停顿"思考",光标停在 ; 后闪烁。
骨架避 minify:HTML 里写的终端骨架文本会被 hugo --minify 吃掉字面空白——int main(void){\n \nreturn 0;} 里那行 4 空格缩进空行被压缩成零宽度。解法是骨架完全在 JS 里用 textContent 写入:
var SKELETON =
"felix@fedora ~>vim helloworld.c\n" +
"#include <stdio.h>\n" +
"\n" +
"int main(void){\n" +
"\n" + // ← 空行占位,printf 从这里开始打
"return 0;\n" +
"}\n";
body.textContent = SKELETON;
打字位置:findInsertOffset 用 int main(void){\n 作锚点,匹配后面的 /^[ \t]*/ 缩进长度。注意不要用 /^\s*/——\s 包含 \n,会把行尾换行也吃掉导致位置偏移。
逐字追加:用 appendChild(createTextNode(char)) 而不是 innerText = innerText + char——后者在 contenteditable 上会触发 input 事件导致光标跳动。
光标:独立 DOM <span class="term-caret">,绝不混入 render() 的 innerHTML 重建——否则每次重建都会把它冲掉,且和 selection 恢复逻辑相互干扰。生命周期:打字期间跟随字符移动 → 结束后定位到 ; 后 → 用户 focus 时被移除交给原生光标。
CSS 细节:.term-body { white-space: pre-wrap } 保证换行和缩进不折叠。
三、Hero 乱码解码级联
效果:Hero 三行文字(h1 / subtitle / desc)同时启动,三态变化:
typeMs 前 → opacity:0(等打字)
typeMs ~ lockMs → 每 60ms 换一个乱码(正在输入的不确定感)
lockMs 后 → 稳定显示真实字符(解码完成)
三行总时长分别 1s / 2s / 3s,越长的文本乱码活得越久——这就是级联解码感。
每字符的两个时间点:
typeMs = i * (duration * 0.6 / len)—— 前 60% 时间打完所有字符lockMs = typeMs + Math.random() * (duration - typeMs)—— 后 40% 留给随机锁定
乱码池:ASCII 符号 + 高频汉字(中英文混排都自然):
'!@#$%^&*()*-+/=[]{}|;:,.<>?中我你他是不为了在有人们和大上地要这好就都可出作生对多着心自会家'
四、磁吸元素 Magnet
效果:光标进入 CTA / 卡片 / chip 的吸引半径后,元素被 strength 系数吸引位移;离开时平滑归位。
var TARGETS = [
{ selector: ".hero-cta .btn", radius: 90, strength: 0.35 },
{ selector: ".note-card", radius: 130, strength: 0.18 },
{ selector: ".chip", radius: 70, strength: 0.25 }
];
线性 falloff:(1 - dist/radius) * strength —— 距离越近吸力越强,过了 radius 直接断。
手感秘籍:吸附时短过渡(0.15s)让元素紧追光标不拖沓,归位时长过渡(0.4s)+ cubic-bezier(0.2, 0.8, 0.2, 1) 有物理回弹感。反直觉但效果好。
五、粘性光标 Blob + Shape Morph
效果:全站隐藏原生光标,换成双层 blob。普通状态是白色圆圈惯性跟随鼠标;hover 可交互元素时,圆圈平滑 morph 成元素轮廓(宽高 + border-radius 精确匹配),被粘到元素位置变成蓝色。
双层结构:
.cursor-dot —— 内点,lerp 0.25,紧追鼠标
.cursor-ring —— 外圈,lerp 0.12,带惯性,hover 时 shape morph
两条独立动画链(关键!):
位置链(JS rAF 每帧 lerp,直接 set transform):
普通:translate(ringX - 16, ringY - 16) ← 减半宽让圆形居中于鼠标
粘性:translate(ringX, ringY) ← 直接 translate 到元素左上角
形状链(CSS transition,不 JS set):
普通:width:32px height:32px border-radius:50%
粘性:width:var(--el-w) height:var(--el-h) border-radius:var(--el-r)
↑ mouseenter 时 JS setProperty 从元素 rect + computedStyle 灌入
为什么两条链分离:transform 用 JS lerp 保证每帧 16ms 精确平滑;shape 用 CSS transition 简洁不费 CPU。绝不能在 CSS 里写 transition: transform——JS 每帧 set transform 会和 CSS transition 打架导致抖动。
与 magnet.js 的双向互补:magnet.js 让元素被光标吸,cursor.js 让光标被元素吸 + 变形。两个独立 mousemove 监听,互不干扰,视觉上产生双向拉扯的张力。
排除区域:.term-body contenteditable 内靠 focusin/out 隐藏 blob,让原生光标接管。
六、纯前端搜索
效果:/notes/ 页面的搜索框,输入后替换当前页卡片为全量搜索结果,清空后恢复原分页。支持 ?q= URL 参数分享。
零构建期依赖:Hugo hugo.toml 加一行让首页额外输出 JSON:
[outputs]
home = ["HTML", "RSS", "JSON"]
生成的 /index.json 包含全部笔记的 title / summary / tags / content。搜索页 fetch("/index.json") 取索引,关键词 AND 匹配。
七、JetBrains Mono 自托管
Google Fonts 国内经常打不开或加载慢。把 woff2 文件直接丢 static/fonts/,@font-face 写进 main.css,head.html 加 preload:
<link rel="preload" href="/fonts/jetbrains-mono-latin.woff2" as="font" type="font/woff2" crossorigin>
八、chip 光标工字形
.chip 是 <span>,浏览器对 inline 文本默认给 cursor: text。加一行 cursor: default 锁死箭头即可。
通用降级策略
/* 减少动效偏好 */
@media (prefers-reduced-motion: reduce) {
html { scroll-behavior: auto; cursor: auto; }
.btn, .card, .skip-link { transition: none; }
.cursor-ring, .cursor-dot { display: none !important; }
/* ... */
}
/* 触屏/无 hover 设备 */
@media (hover: none), (pointer: coarse) {
html { cursor: auto; }
.cursor-ring, .cursor-dot { display: none !important; }
}
cursor.js 开头也做了 JS 层的 early return:
if (window.matchMedia("(hover: none), (pointer: coarse)").matches) return;
踩坑总结
| 坑 | 根因 | 修 |
|---|---|---|
| Hugo minify 吃掉终端骨架空白 | HTML 字面空白被压缩 | 骨架移到 JS textContent,字符串不走 minify 管道 |
\s* 正则吃换行 | \s 含 \n | 用 [ \t]* 只匹配空格 tab |
| 帘子后全屏白 | curtain-wrap 写了 background | 兜底色移到两个子 div,父元素透明 |
curtainAutoNone keyframe 无效 | pointer-events 不是可动画属性 | 直接给父元素 pointer-events: none |
innerText = innerText + char 光标跳 | 触发 contenteditable input 事件 | appendChild(createTextNode(char)) |
| 高亮 cursor 混 innerHTML 重建 | render() 每次重建把 caret 冲掉 | caret 是独立 DOM,render 后重新 insert |
| transition: transform + JS set 抖动 | CSS transition 和 JS rAF 打架 | transform 纯 JS set,只给 shape 属性加 transition |
.chip 工字形 cursor | inline span 默认 cursor:text | cursor: default |