变量字体最容易被做成一个炫技 demo:鼠标靠近字母,wghtwdth 跟着坐标变化。真正上线时,问题才出现:字体文件未加载时标题跳动,移动端没有 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 编码在字体文件中。wghtwdthslntitalopsz 是注册轴,字体也可以定义四字符、区分大小写的自定义轴。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-weightfont-stretch 显示。

注册轴优先使用高层 CSS 属性:wghtfont-weight,宽度在当前生产兼容路径使用 font-stretch,斜体与倾斜使用 font-style,光学尺寸使用 font-optical-sizingCSS 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 属性,把自定义轴集中组合,每帧合并输入,并让静态可读实例承担所有失败路径,字体形变才会成为可靠界面,而不是只能在演示机上运行的玩具。

Sources