变量字体最容易被做成一个炫技 demo:鼠标靠近字母,wght 和 wdth 跟着坐标变化。真正上线时,问题才出现:字体文件未加载时标题跳动,移动端没有 hover,键盘无法操作,Reduce Motion 仍持续抖动,宽度轴改变换行,每个 pointer event 都触发文本塑形与绘制。变量字体不是一种特效属性,而是字体资源、轴合同、输入模型和排版语义共同组成的系统。
本文面向已经使用 @font-face 与 CSS 动画、准备把变量字体用于首页英雄字、导航或交互标题的开发者。标准轴与 font-variation-settings 已有广泛实现;2026 年 CSS Fonts Level 4 把 font-width 作为现代名称,但 MDN 仍标记其 Limited availability,font-stretch 作为 legacy alias 具有更广生产支持。三段精确代码已进入 examples:两段 CSS 通过静态契约测试,指针到字体轴的 TypeScript 映射通过类型检查。真实字体轴、文本塑形成本和跨浏览器视觉结论仍是 source-reviewed。
先读取字体,而不是猜轴范围
变量字体把一个或多个 variation axes 编码在字体文件中。wght、wdth、slnt、ital 与 opsz 是注册轴,字体也可以定义四字符、区分大小写的自定义轴。MDN 变量字体指南 解释了轴、范围与注册/自定义区别;一个字体支持变量格式,不代表它同时具有所有五个轴。
实现前应从字体元数据或字体作者文档记录每个轴的 min、default、max、单位和设计意图。不要假定 weight 永远是 100–900、width 永远是 75–125,也不要把轴值外插。CSS 会把超出字体支持范围的值 clamp 到边界,但输入继续增长而视觉停住,会造成指针迟滞和不可理解的控制感。
同一个轴还可能改变 glyph advance、kerning、笔画对比与可读性。wdth 明确会改变文字测量,wght 在不同字体中也不保证完全不影响指标。首页单行大字可以容忍可控宽度变化,正文段落则可能不断换行并触发布局。先划定作用区域,不能把全站 body 当实验画布。
用 @font-face 声明真实可选范围
变量字体应在 @font-face descriptor 中暴露可用范围,让浏览器的字体匹配理解连续字重和宽度:
@font-face {
font-family: 'Site Variable';
src: url('/fonts/site-variable.woff2') format('woff2');
font-weight: 200 900;
font-stretch: 75% 125%;
font-display: swap;
}
.hero-word {
--weight: 720;
--width: 100%;
--grade: 0;
font-family: 'Site Variable', system-ui, sans-serif;
font-weight: var(--weight);
font-stretch: var(--width);
font-optical-sizing: auto;
font-variation-settings: 'GRAD' var(--grade);
}
范围必须来自实际字体,示例数字不是本站字体测试结果。MDN 的 font-weight descriptor 文档 说明一对数值如何为变量字体声明连续范围。字体不支持某个自定义 GRAD 轴时,该 setting 会被忽略,fallback 字体仍以稳定 font-weight 和 font-stretch 显示。
注册轴优先使用高层 CSS 属性:wght 用 font-weight,宽度在当前生产兼容路径使用 font-stretch,斜体与倾斜使用 font-style,光学尺寸使用 font-optical-sizing。CSS Fonts Level 4 明确建议不要用低层 font-variation-settings 替代这些属性,因为高层属性可以独立级联并参与字体选择。自定义轴或没有对应属性的能力才放进低层列表。
规范当前使用 font-width 作为现代名称,并把 font-stretch 定义为行为相同的历史别名;由于新名称支持仍有限,不能只按 Working Draft 改写生产 CSS。可以保持 alias、用 PostCSS 转换,或在目标矩阵完整后再迁移,页面上必须明确标注这一区别。
低层轴列表是一个整体
font-variation-settings 接受多个四字符 tag 与数值。修改一个轴时需要重新声明整份列表;两个动画端点只有在包含相同轴集合时才能逐项插值,否则可能发生离散切换。MDN 参考 与 CSS Fonts 规范都描述了这一行为。
CSS custom properties 可以把轴值拆成独立 token,再在一个声明中组合,避免不同模块互相覆盖:
.display-type {
--grade: 0;
--custom-roundness: 40;
font-variation-settings:
'GRAD' var(--grade),
'ROND' var(--custom-roundness);
}
.display-type:focus-visible {
--grade: 80;
--custom-roundness: 70;
}
同一组件只能有一个地方最终组装 axis list。Hover、滚动和指针模块若分别写完整 fontVariationSettings,后写者会抹掉前者。更稳妥的是它们只修改各自拥有的 token,组件样式负责组合,并为冲突定义优先级。
输入先标准化,再映射到轴
指针坐标是像素,字体轴有自己的数值范围。运行时应先将坐标标准化到 0–1,再 clamp、应用方向和曲线,最后映射到字体真实边界。移动端 coarse pointer、触控滚动和键盘没有连续 hover,必须有稳定默认值与可操作替代。
type AxisRange = { min: number; max: number; initial: number };
export function initVariableWord(word: HTMLElement, weight: AxisRange, width: AxisRange) {
const reduced = matchMedia('(prefers-reduced-motion: reduce)');
if (reduced.matches || matchMedia('(pointer: coarse)').matches) return () => {};
let frame = 0;
let progressX = 0.5;
let progressY = 0.5;
const mix = (range: AxisRange, progress: number) =>
range.min + (range.max - range.min) * Math.min(1, Math.max(0, progress));
const render = () => {
frame = 0;
word.style.setProperty('--weight', mix(weight, 1 - progressY).toFixed(0));
word.style.setProperty('--width', `${mix(width, progressX).toFixed(2)}%`);
};
const onMove = (event: PointerEvent) => {
const rect = word.getBoundingClientRect();
progressX = (event.clientX - rect.left) / rect.width;
progressY = (event.clientY - rect.top) / rect.height;
if (!frame) frame = requestAnimationFrame(render);
};
const reset = () => {
if (frame) cancelAnimationFrame(frame);
frame = 0;
word.style.setProperty('--weight', String(weight.initial));
word.style.setProperty('--width', `${width.initial}%`);
};
word.addEventListener('pointermove', onMove, { passive: true });
word.addEventListener('pointerleave', reset);
return () => {
word.removeEventListener('pointermove', onMove);
word.removeEventListener('pointerleave', reset);
if (frame) cancelAnimationFrame(frame);
word.style.removeProperty('--weight');
word.style.removeProperty('--width');
};
}
代码每帧最多写一次,但仍在每个事件读取 rect;生产实现应在 pointer enter、resize 或 ResizeObserver 时缓存几何。它没有性能结论,实际文本塑形、paint 与布局必须用 trace 验证。连续更新宽度轴尤其可能改变元素尺寸,英雄字应有明确容器与溢出策略,不能让整页导航随鼠标抖动。
图 1:动态路径和字体失败路径共享一个可读初始状态;本站原创 1400×800 程序化 SVG。
键盘、触摸与 Reduce Motion
若轴值本身是用户可调设置,应使用带 label 与 output 的原生 range input,让键盘、触摸和辅助技术都能改变它;英雄字的指针反应只是装饰时,不必把每个瞬时值朗读给 VoiceOver,但可见标题必须仍是实际文本。Canvas 或图片中的字形不能成为唯一标题。
Hover 与 focus 要表达等价状态。导航项通过宽度变宽表示当前项时,还需要 aria-current、颜色、下划线或其他非运动线索。字重不能低到损害阅读,宽度不能压缩到字母难辨,动画也不能让整段正文持续闪变。长时间停留的页面还应监听 Reduce Motion 媒体查询变化,在偏好切换时立即停止 RAF 并恢复默认轴,而不是只在初始加载读取一次。
Reduce Motion 下停止指针跟随、滚动轴和自动循环,直接使用字体设计的 default 或本站选定的可读实例。不要只把 interpolation 变慢;持续缓慢变形仍是持续运动。触控设备可以用点击切换少量命名状态,也可以完全保留静态字形,不需要模拟不存在的 hover。
字体加载与失败回退
变量字体把多个实例放进一个文件,不代表它一定比当前实际使用的几个静态子集更小。web.dev 字体最佳实践 要求按真实资源、字符集和加载路径评估。中文站点尤其要关注字形覆盖:拉丁变量字体可能只承担英文大标题,中文正文仍由另一字体提供,不能宣称“一份文件覆盖全站”。
WOFF2、unicode-range、预加载和 subset 应依据首屏需要决定。预加载未在首屏使用的大字体会争夺网络优先级;font-display: swap 保证文字先显示,但 fallback 与目标字体指标差异可能造成布局变化。可以选择更接近的 fallback,并在真实字符串上比较行高与宽度;不能靠隐藏文字等待字体完成。
font-optical-sizing: auto 只在字体提供 opsz 时由浏览器选择合适值。手工动画 opsz 会覆盖排版意图,除非产品确有明确设计并测试小字号可读性,否则让它保持 auto。
字体加载失败时,高层属性在 fallback 上仍会尽可能选择字重和宽度,自定义轴被忽略。页面不应检测失败后隐藏交互标题;静态文字、链接和焦点框都必须保留。加载完成后的运行时可以增强,但不应让首屏等待指针脚本。
性能验证与失败模式
固定一段代表性文字,分别录制静态、只改 weight、只改 width、双轴指针与多个元素同时变化。检查 style、layout、paint、字体任务和主线程长任务;再测试字体冷缓存、swap 完成、窗口 resize、页面离屏与路由 cleanup。只看 GPU raster 图或平均 FPS 不能解释文字换行与输入响应。
常见失败包括:猜测轴范围;把注册轴全部塞进低层 setting;多个模块覆盖 axis list;每个 pointer event 立即写样式;宽度变化引发布局抖动;字体未加载时隐藏标题;只做 hover;Reduce Motion 仍持续变形;把变量字体文件数量减少等同于传输一定更小;用截图证明实时性能。
变量字体交互的高水准不在于轴动得多,而在于字体设计、输入和排版保持一致。读取真实轴合同,优先使用可级联的 CSS 属性,把自定义轴集中组合,每帧合并输入,并让静态可读实例承担所有失败路径,字体形变才会成为可靠界面,而不是只能在演示机上运行的玩具。