一口气给我的 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 Cardhead.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 工字形 cursorinline span 默认 cursor:textcursor: default