这次我用 Agent 做了一个很小的前端页面:一张可以打开、播放、保存的生日信纸。
需求本身不复杂。页面打开后,先展示一张清透的白色横线信纸,信纸从左到右慢慢展开;展开后按书信格式逐字写出祝福文字;写完后出现两个按钮,一个重新播放,一个保存信纸。保存时只导出信纸本身,不带页面背景、阴影、按钮和后面的庆祝动画。
听起来像一个普通的前端小活。做完之后我发现,很多细节只有在真实写出来、录屏看一遍、再调一轮时才会暴露。尤其是中文手写字体、自然换行打字机、移动端保存图片和响应式信纸比例,组合到一起以后,常见方案会开始变形。
原始需求
我把原始需求脱敏后,大致是这样:
- 做一个单文件网页,所有代码写在
index.html。 - 主体是一张逼真的信纸,用 SVG 画出来,清透白色,淡灰蓝横线,有细腻纸纹。
- 页面打开后,信纸从左到右展开,要有一点真实纸张的立体感和阴影。
- 展开后开始打字机效果,内容按正式书信格式排版。收信人、正文、落款都要像认真写出来的手写字。
- 正文里保留 🎂 和 🎧,最后补一句“祝你天天开心,永远 18~”,可以换行。
- 动画结束后,信纸下方出现两个按钮:重新播放、保存信纸。
- 点击重新播放时刷新页面。点击保存信纸时,手机上尽量走系统分享或保存入口,PC 上下载 PNG。
- 保存时只保留干净信纸,不带页面投影、按钮、背景和后续飘落动画。
- 三端适配,桌面、平板、手机都要看起来像同一张信纸。
- 整体气质要温柔、清秀、克制,背景是浅雾蓝,按钮在信纸下方浮现。
后来又追加了两个效果:打字光标换成一支细尖钢笔/签字笔;全部动画结束后,少量 🎂 从页面上方轻轻飘落并淡出。
用到的 skills
这轮主要用了四个 skills:
$grill-me:把模糊审美需求拆成一组可选择的问题,先定方向再写代码。$design-taste-frontend:约束页面不要走模板感,关注材质、动效、响应式、按钮位置和视觉克制。$kill-ai-slop:写完后检查是否有常见 AI 味,比如过度装饰、泛紫渐变、套路化布局、无意义说明文字。$personal-website-post-writer:把这次过程整理成文章,脱敏、参考旧文风格、按 personal-website 的格式落稿。
这几个 skill 的作用不一样。grill-me 解决“我要什么”;design-taste-frontend 解决“怎么做得像一个认真设计过的页面”;kill-ai-slop 解决“哪里看起来像模型默认生成”;personal-website-post-writer 负责把过程沉淀成可复用记录。
grill-me 问过的问题
这次有几个选择很影响最后的实现。
第一个问题是信纸的材质。最后选的是“清透白色信纸,淡灰蓝横线,细腻纸纹”。这个选择决定了 SVG 里不能只画一块白色矩形和几条线,需要加轻微渐变、纸纹 noise、纤维线条和边缘高光。
第二个问题是展开动画。最后选的是“真实纸张展开,有轻微立体感和阴影”。所以实现时用了 perspective、rotateY、scaleX 和一层随纸张展开的阴影,而不是简单的宽度从 0 到 100%。
第三个问题是打字速度。最后选的是“温柔但不拖沓”。代码里给不同字符设置了不同延迟:普通字更快,逗号稍停,句号和换行停久一点。这样读起来有书写感,但不会慢到让人等得烦。
第四个问题是字体气质。最后选的是“清秀工整,像认真写的信”。前面试过字体后,用户反馈字体 OK,但加载抖动明显,于是后面把字体加载方案单独处理。
第五个问题是页面环境。最后选的是“轻环境,有浅雾蓝背景和柔和投影”。背景只保留雾蓝、轻网格和柔光,不做大面积渐变球和装饰图形。
第六个问题是保存范围。最后选的是“只保存信纸本身”。这让保存逻辑不能截图整个页面,只能重新在 canvas 里画干净信纸和文字。
第七个问题是落款。最后定为右下角两行,略微缩进对齐,并作为手写文本的一部分保留。
第八个问题是结尾飘落 🎂。推荐方案是“少量、慢速、轻盈地从信纸上方飘过,最后淡出,不堆积在信纸上”,最终也按这个方向实现。
第九个问题是笔光标。推荐方案是“细尖钢笔/签字笔”,尺寸小一点,贴着字走,不做成抢眼图标。
这些问题看起来都很细,但它们减少了后面反复返工的范围。审美类需求最怕一句“好看一点”,因为每个人脑子里的“好看”差很多。
一开始的编写思路
这个页面没有上框架。需求要求所有代码写在一个 index.html 里,所以结构是 HTML、CSS、JavaScript 三段放在同一个文件。
页面主体分成四层:
- 页面背景层:浅雾蓝背景、轻网格、柔光。
- 信纸层:SVG 画纸张、横线、纸纹、边缘高光和装饰曲线。
- 文本层:绝对定位在信纸上方,按标题、正文、落款三个区域排版。
- 操作和庆祝层:按钮在信纸下方,🎂 飘落挂在页面层,不进入信纸内部。
信纸用 SVG 的原因是可控。横线位置、纸张圆角、纸纹透明度、边缘描边都能在同一个坐标系里调。保存 PNG 时,也可以在 canvas 里用同样的坐标重新画出接近一致的结果。
展开动画用 CSS 做。信纸外壳一开始 rotateY(-82deg) scaleX(0.18),从左边展开,最后回到 rotateY(0deg) scaleX(1)。阴影单独一层,跟着纸张从窄到宽。这样纸张有一点立体感,但不会拖太久。
按钮没有一开始就出现。书写完成后按钮淡入,从信纸下方浮现。这样用户注意力先落在信纸和文字上,操作入口在最后出现。
打字机的实现
第一版打字机用了最常见的方式:逐字追加文本。
这个方案很快写完,但中文自然换行会抖。原因是浏览器每追加一个字符,都要重新计算整段文本的换行。中文没有英文那种天然空格分词,行尾的几个字很容易在某个时间点换到下一行,然后下一帧又回去。用户录屏里看到的“顿一下”“符号出现又消失”就是这种布局重排造成的。
最后保留的方案是:
- 一开始把所有字符都渲染成单独的
span。 - 每个
span占据原本的排版位置,但透明度是 0。 - 打字时只把对应字符的透明度切到 1。
这样浏览器在动画开始前已经算好了整封信的自然换行。动画过程中只改 opacity,不再改变文本内容长度,也就不会触发整段重排。
伪代码大概是:
function prepareReveal(node, text) {
node.textContent = ''
for (const char of Array.from(text)) {
const span = document.createElement('span')
span.className = 'typing-char'
span.textContent = char
node.appendChild(span)
}
}
播放时用 requestAnimationFrame 对照时间点显示字符。相比 setTimeout 串起来,这种方式在标签页切换、掉帧和批量显示时更稳。
手写字体加载的坑
前面试过外部字体。问题很快出现了:页面刚开始用的是兜底字体,过一会儿手写字体加载完,整段文字突然换字形。用户肉眼看到的是字体抖了一下。
后来打开网络面板,看到字体被拆成很多 unicode-range 分片,每个请求都接近 10 秒。中文字符一多,浏览器会按用到的字符逐步请求对应字体文件,于是打字过程中可能持续发生 font swap。
最后保留的处理方式是:
- 只保留这封信需要用到的字符。
- 生成一个字体子集。
- 把字体子集转成 base64,内联到
@font-face。 font-display使用block,并在打字前显式等待字体加载。
这样页面不再请求一堆外部字体文件,首屏和打字过程也不会因为字体切换抖动。
需要注意,font-display: swap 对普通正文是常见选择,但在这种逐字手写动画里不合适。用户盯着文字出现,字体切换会非常明显。这里宁愿让打字晚一点开始,也不要写到一半换字体。
等待字体的代码形态类似:
await Promise.race([
document.fonts.load('34px "Birthday Hand"', fullLetter),
sleep(700),
])
这里加 race 是为了避免极端情况下永远等字体。字体已经内联后,正常不会等到 700ms。
笔光标的坑
一开始的光标是普通竖线,后来换成一支笔。竖线光标很好处理,因为它几乎没有视觉体积。笔不一样,它有宽度、高度、旋转角度和笔尖锚点。
如果把光标动画挂在正在显示的字符上,会出现一个很隐蔽的问题:段尾的符号和落款文字会随着光标闪烁一起消失再出现。用户录屏里看到 ~、。、署名几个字反复闪,就是这个原因。
最后把光标独立成一个绝对定位元素。每显示一个字符,就用该字符的 getBoundingClientRect() 计算位置,把笔移动到字符右侧。文字的显示和笔的移动分开,段尾字符就不会再被光标动画影响。
这支笔后来也调了几轮。粗笔会抢戏,太低又不像正在书写。最后用 CSS 画了一个细尖签字笔:主体很窄,前端是小三角笔尖,颜色贴近墨色,透明度压低。
响应式排版的坑
桌面端后来出现过一次明显溢出。原因来自一次看似合理的修改:PC 端正文行高太小,于是把桌面行高加大。行距舒服了,但短窗口里文字被挤出信纸,甚至压到按钮上。
这次溢出不是单纯的行高问题。字号和信纸尺寸没有绑定在一起,短窗口里纸已经缩小,字还按浏览器宽度继续偏大。
信纸宽度用了类似这样的约束:
.card-wrap {
width: min(92vw, 760px, calc((100dvh - 118px) * 0.7755));
}
这意味着在桌面短窗口里,信纸会因为高度不够而缩小。如果文字仍然按 vw 算,窗口很宽时字会偏大,但纸已经被高度压小了。
最后的处理是给信纸加 container-type: inline-size,桌面字号和边距改用 cqw。字跟着信纸实际宽度走,而不是跟着浏览器宽度走。
.paper-scene {
container-type: inline-size;
}
.letter-body {
font-size: clamp(20px, 3.62cqw, 28px);
line-height: 2.05;
}
另外给短桌面窗口加了保护档,在 max-height: 860px 时略微降低字号和行高。视觉上仍然松,但不会再把落款挤出信纸。
保存信纸的处理
保存不能直接截屏。页面上有背景、投影、按钮、飘落蛋糕,这些都不该进入保存结果。
所以保存按钮走的是 canvas 重绘:
- 创建一个固定尺寸 canvas。
- 重新画白色信纸、横线、纸纹和装饰曲线。
- 使用同一个手写字体画标题、正文和落款。
- 导出 PNG。
- 在支持分享文件的移动端尝试
navigator.share,否则下载。
这个方案的代价是页面渲染和 canvas 渲染要维护两套排版参数。好处是导出的 PNG 很干净,不受页面阴影、按钮和动画影响。
如果以后要减少维护成本,可以把纸张坐标、文字区域、字体大小、行高抽成一份配置,DOM 和 canvas 都读取同一组数据。
飘落 🎂 的处理
飘落效果是最后加的。选择很明确:少量、慢速、轻盈、淡出,不堆积。
实现时没有把 🎂 放进信纸层,而是挂在页面的固定定位层:
<div class="cake-fall-layer" aria-hidden="true"></div>
书写结束、按钮出现后,创建 8 个 span,每个设置不同的初始位置、延迟、时长、尺寸、漂移和旋转角度。动画结束后清空容器。
这个细节看似小,但会影响保存逻辑。只要庆祝动画不在信纸内部,保存干净信纸就不会被破坏。
踩坑记录
这次主要踩了六个坑。
第一个坑是字体分片请求。中文字体常常按 unicode-range 拆成很多文件,外部加载时可能出现十几条字体请求。对普通网页还好,对逐字动画非常明显。处理方法是子集化并内联,打字前等待字体可用。
第二个坑是逐字追加导致自然换行重排。常见打字机 demo 很多只处理英文单行或固定宽度文本,换到中文多行正文就会跳。处理方法是预先渲染所有字符,占位不显示,播放时只改透明度。
第三个坑是把光标效果绑到字符上。光标闪烁会影响字符本身的 opacity,段尾字符看起来会消失再出现。处理方法是让光标成为独立元素,用字符位置驱动它移动。
第四个坑是桌面行距。行距加大后,如果字号仍按视口算,短窗口会把文本挤出信纸。处理方法是容器查询单位 cqw 加短窗口保护。
第五个坑是移动端保存。不能假设浏览器一定能“保存到相册”。Web 页面只能尽量走 navigator.share 或下载文件。iOS 和 Android 的最终入口由系统决定。
第六个坑是审美效果容易越界。纸纹、阴影、展开动画、蛋糕飘落和笔光标都很容易抢主角。每个效果都要压低一点,让信纸和文字保持中心位置。
以后可以直接复用的方法
以后再做类似页面,我会直接复用这套判断。
特殊字体
如果页面强依赖某个特殊字体,尤其是中文手写字体:
- 不要直接引一整个远程字体包。
- 先确认实际需要的字符集合。
- 做字体子集。
- 能接受文件变大时,直接内联到
@font-face。 - 打字、签名、标题这类视觉焦点内容,播放前等待字体加载。
- 用 DevTools 的 Network 面板确认没有一堆慢字体请求。
font-display: swap 适合普通阅读,不适合逐字出现的手写动画。
中文打字机
多行中文打字机尽量不要用 textContent += char。
推荐流程:
- 先把完整文本拆成字符。
- 每个字符渲染成一个 span。
- span 保留排版位置,初始透明。
- 用
requestAnimationFrame按时间点逐个显示。 - 标点和换行给不同延迟。
这个流程可以保留自然换行,又能避免排版抖动。
光标跟随
如果光标有体积,比如笔、手、贴纸:
- 不要把光标绑在字符内部。
- 用独立绝对定位元素。
- 每次显示字符后读取该字符的 DOMRect。
- 用字符右侧位置更新光标。
- 光标透明度和文字透明度分开控制。
信纸类响应式
固定比例视觉稿要同时考虑宽度和高度。只用 vw 很容易在桌面短窗口里出问题。
可复用规则:
- 外层宽度用
min(vw, maxWidth, heightBasedWidth)。 - 字号、边距用容器单位
cqw。 - 对短窗口单独设一档字号和行高。
- 用脚本或浏览器自动化测元素边界,确认正文、落款、按钮没有重叠。
保存干净图
如果页面上有阴影、按钮、背景动画,但导出图不该包含它们:
- 不要截整个页面。
- 用 canvas 重绘导出区域。
- 把导出区域和页面装饰分层。
- 页面庆祝效果挂到导出区域之外。
- 移动端优先尝试 Web Share API,失败后回退下载。
审美类需求
我会继续先用 grill-me 问问题。审美需求里的“好看”“精致”“温柔”需要落到可执行选项上,直接开写很容易在后面反复改方向。
这次效果能收住,很大程度来自前面那些选择:白色信纸、淡灰蓝横线、轻纸纹、少量蛋糕、细笔、保存干净信纸。每一个选择都不大,但组合起来决定了页面气质。
最后
这张生日信纸网页最后只有一个 index.html,代码量也不算大。但它覆盖了几个前端里很容易被低估的问题:字体加载、中文排版、动画重排、响应式比例、canvas 导出和移动端保存。
我最想保留下来的经验是:常见动画放到具体语境里,常见方案未必够用。打字机效果是常见动画,但中文手写信、自然换行、笔光标、保存干净图和三端适配放在一起,就要重新设计实现方式。
Agent 在这类任务里挺适合做两件事:先把模糊审美需求问清楚,再在用户录屏反馈后快速排查和迭代。中间踩到的坑也值得记录下来,下次遇到“特殊字体 + 中文动画 + 导出图片”的需求,可以少走很多弯路。