用纯 CSS 让文本适配容器 — 无需 JavaScript
人人都在写一个 JavaScript hook,通过缩小 font-size 让文本塞进容器。这里有一个完全可用、零 JS 的替代方案,使用容器查询单位、clamp() 和按语言的连字符断词 — 包括让很多人放弃的层叠陷阱。
大号展示标题有个恼人的毛病:在你的设计里完美贴合的文本,一旦容器变窄、或换成另一种语言文案变长,就会溢出。多年来标准解法都是 JavaScript — 测量渲染后的文本,与其容器比较,然后不断缩小字体直到塞得下。几乎每个人都在重新发明这件事:一个自制的 useFitText hook、一个 ResizeObserver 循环,或者像 fitty、textFit、react-textfit 这样的库。
本文展示一个完全可用、零 JavaScript 的替代方案:借助 CSS 容器查询单位让字体随其容器缩放,再加上带连字符的正确断行。没有 hook,没有测量,水合后也没有布局跳动。
人人都在写的那段 JavaScript
套路总是一样。先用某个基准尺寸渲染文本,读取文本宽度和其父元素宽度,如果文本更宽,就减小字号再试一次。一个最小版本是这样:
import { useLayoutEffect, useRef, useState } from "react";
// Shrinks font-size by 1px until the text fits its parent.
export function useFitText() {
const parentRef = useRef<HTMLElement>(null);
const textRef = useRef<HTMLElement>(null);
const [fontSize, setFontSize] = useState<number>();
useLayoutEffect(() => {
const parent = parentRef.current;
const text = textRef.current;
if (!parent || !text) return;
const current = parseFloat(getComputedStyle(text).fontSize);
if (parent.offsetWidth < text.offsetWidth) {
setFontSize(current - 1); // re-render, measure again
}
}, [fontSize]);
return { parentRef, textRef, fontSize };
}它能用,但代价实实在在:
- 它在组件挂载之后运行,所以文本先以错误尺寸绘制,然后跳变 — 明显的布局跳动,也拖累 CLS。
- 在有服务端渲染的框架里,服务端发送一个尺寸,客户端在水合后再纠正,可能造成闪烁。
- 它为一件本质上属于布局的事情附带了 JavaScript。
- 每次 1px 的循环会引发多次重新渲染,并强制同步读取布局 — 这正是你不想放到主线程上的那类工作。
为什么 JavaScript 曾是答案 — 直到最近
该诚实说说这些 hook 为何存在。在容器查询进入浏览器之前,CSS 根本无法相对于某个元素来设定字体尺寸。你可以用 vw 相对于视口设定尺寸,但侧边栏 400px 里的标题和 1200px hero 里的同一标题,会得到完全相同的基于 vw 的尺寸,因为 vw 对容器一无所知。
容器查询长度单位改变了这一点。它们直到 2023 年前后才在各浏览器中安全可用,所以在此之前写的任何字体适配代码都没有 CSS 选项,只能出于无奈求助 JavaScript。这个限制现在没有了 — 这正是本文的要点。
基础构件:容器查询单位
容器查询长度单位是查询容器尺寸的百分比。用 container-type: inline-size 把一个元素标记为容器,它的后代就能使用 cqi — 其中 1cqi 等于容器行内尺寸的 1%(在横向书写模式下即其宽度)。
.card {
container-type: inline-size; /* this element is now a query container */
}
.card .title {
/* 10% of .card's width — scales with the container, not the viewport */
font-size: 10cqi;
}现在同一个标题在 400px 的卡片里是 40px,在 1200px 的卡片里是 120px — 自动完成,无需测量。这正是容器查询出现前所缺的那块拼图:一个会响应父元素的字号。
用 clamp() 约束尺寸
裸的 cqi 会在超大容器上不断变大,在极小容器上缩到看不见。用 clamp() 把它包起来,设定一个下限和一个上限 — 通常把移动端尺寸作为最小值、桌面端尺寸作为最大值:
.card {
container-type: inline-size;
}
.card .title {
font-size: clamp(
2rem, /* min — never smaller than the mobile size */
10cqi, /* fluid — scales with the container width */
4.5rem /* max — never larger than the desktop size */
);
}可以这样读:随容器流动,但绝不小于 2rem、也绝不大于 4.5rem。中间那一项(cqi 斜率)控制字体缩放的快慢;两端的边界就是你设计里的移动端和桌面端字号。如果这些尺寸放在自定义属性里,你就能全部由它们驱动:
.title {
--size-desktop: 72;
--size-mobile: 32;
}
.title .fit {
font-size: clamp(
calc(var(--size-mobile) * 1px),
10cqi,
calc(var(--size-desktop) * 1px)
);
}会让你搭进一小时的坑:层叠
这就是让人放弃并断言"容器查询不好使"的坑。容器解析正确,单位也对,可字体就是不变尺寸。原因几乎总是层叠,而不是容器查询。
这个症状极具迷惑性。你的字体在每个容器里都钉死在它的最大值上,无论容器多窄 — 而这恰恰是 cqi 找不到容器、回退到视口时的表现。于是你去查显而易见的地方:容器设置对吗?你确认 container-type: inline-size 在,你遍历 DOM 确认祖先确实是容器,你读取元素计算后的 container-type,它显示 inline-size,你测量容器宽度,也没错。一切都对。可字体依旧钉着。你找错地方了。
最快的出路是别再信自己的规则,独立地证明这个单位。往同一个容器里丢一个一次性探针,除了一个裸的 cqi 值别无他物,然后测量它:
// Inject a bare probe as a child of the same container:
const probe = document.createElement("span");
probe.style.fontSize = "10cqi";
container.append(probe);
getComputedStyle(probe).fontSize;
// 90px inside a 900px container, 36px inside 360px → cqi works fine.
probe.remove();如果探针能正确缩放,而你真正的元素依旧钉死,那就是全部诊断:容器查询工作得完美无缺,是别的东西覆盖了你的 font-size。这就是层叠 — 罪魁通常是一个你忘了的更宽泛的选择器。
设计系统常用类似 .title p, .title span { font-size: … } 的规则给嵌套元素设定排版。这个选择器的特异度是 (0,1,1)。如果你的适配规则用单个类命中同一个元素 — .fit { … },特异度 (0,1,0) — 设计系统的规则获胜并钉死字号,于是你的 clamp() 被悄无声息地覆盖。两者甚至可能处于同一个层叠层,所以 layer 也救不了你。更糟的是,那条更宽泛的规则很可能是基于视口的,这正是为何尺寸看起来像视口回退 — 你实实在在看到的是另一条规则获胜。
修法是让你的规则至少具有相同的特异度。把它嵌套在容器类之下最简单 — .title .fit 是 (0,2,0),胜过 .title p 的 (0,1,1):
/* Loses to `.title p { font-size: … }` — same layer, higher specificity */
.fit { font-size: clamp(2rem, 10cqi, 4.5rem); }
/* Wins: (0,2,0) > (0,1,1) */
.title .fit { font-size: clamp(2rem, 10cqi, 4.5rem); }如果你的容器查询字号看起来被忽略了,检查该元素,看看究竟是哪条规则在设定 <code>font-size</code>。十有八九是设计系统里更宽泛的选择器赢了层叠 — 容器查询本身没问题。
长单词问题 — 以及为何这不是容器查询的失败
有一件事 cqi 确实做不到:它按容器宽度缩放字号,而不是按某个具体字符串的长度。单个不可断开的单词在算得的尺寸下仍可能比容器还宽。德语是经典的肇事者 — 像 Fremdsprachenkenntnisse 这样的词,即便字号已适配容器,也会大方地溢出一个窄框。
这不是方法的缺陷;这是它诚实的边界。旧的 JavaScript hook 处理这种情况的办法,是测量那个具体的词并继续缩小。在 CSS 里你换个办法 — 让单词断开,而不是缩小整个标题。两个属性就能搞定:
.title .fit {
font-size: clamp(2rem, 10cqi, 4.5rem);
/* Break a too-long word instead of overflowing the box */
overflow-wrap: break-word;
/* Balance line lengths for nicer multi-line headings */
text-wrap: balance;
}overflow-wrap: break-word 是安全网:如果一个单词放不进一行,浏览器会把它断开,而不是任由它溢出。text-wrap: balance 是润色 — 它让各行长度更均衡,使换行后的标题不会以一个孤零零的单词结尾。
带连字符断词 — 自动,且按语言
不带连字符地把单词从中间断开显得很糙。CSS 能用 hyphens: auto 在语言学上有效的位置插入一个真正的连字符。值得理解的关键:断词是语言相关的。浏览器使用元素语言对应的断词词典,而语言是从 lang 属性读取的。
.title .fit {
font-size: clamp(2rem, 10cqi, 4.5rem);
hyphens: auto; /* insert a real hyphen at valid break points */
overflow-wrap: break-word; /* last-resort break for words with no valid point */
text-wrap: balance;
}优雅之处在于:如果你的文档已经在根节点设定了语言 — 德语页面为 <html lang="de"> — 断词就会自动生效,逐语言生效,无需额外接线。德语页面用德语词典断词,英语页面用英语词典。只要 lang 反映内容,你就能在每种语言里免费获得正确的连字符。
把 overflow-wrap: break-word 保留在旁边。hyphens: auto 处理它会断的词;回退机制兜住其余一切(URL、品牌名、生造的复合词),这样就永远不会溢出。
组合到一起
这是完整、无依赖的组件。注意这个双元素结构:一个作为容器的外层元素,和一个从中读取 cqi 的内层元素。元素无法用自己的容器来设定自身字体,所以你需要父/子拆分。
<h2 class="title" style="--size-desktop: 72; --size-mobile: 32">
<span class="fit">Learn anything, beautifully</span>
</h2>.title {
/* The container the inner text scales against */
container-type: inline-size;
}
/* Nested selector so it out-specifies design-system typography rules */
.title .fit {
display: block;
/* Fluid between the mobile and desktop sizes, driven by container width */
font-size: clamp(
calc(var(--size-mobile) * 1px),
10cqi,
calc(var(--size-desktop) * 1px)
);
/* Never overflow: break long words, hyphenate by language, balance lines */
overflow-wrap: break-word;
hyphens: auto;
text-wrap: balance;
}这就是全部功能:一个随容器缩放、遵守移动端下限和桌面端上限、永不溢出、并在任何语言里都带正确连字符断行的标题 — 而且它不附带一个字节的 JavaScript。
CSS 对比 JavaScript,实话实说
| 关注点 | JS 适配文本 hook / 库 | 纯 CSS(cqi + clamp) |
|---|---|---|
| 响应容器宽度 | 是(通过测量) | 是(通过 cqi) |
| 响应精确的字符串长度 | 是 | 否 — 改由 overflow-wrap / hyphens 兜底 |
| 加载后的布局跳动 | 常见(先测量再调整尺寸) | 无 — 首次绘制即正确 |
| 在水合前的 SSR 阶段可用 | 否 | 是 |
| 附带的 JavaScript | 有 | 无 |
| 主线程开销 | 回流 + 重新渲染 | 零 |
| 按语言的断词 | 手动 | 通过 hyphens: auto 内建 |
JavaScript 仍然胜出的唯一一列是按字符串的精确适配 — 强制某个特定标题以能容纳的最大尺寸恰好占据一行。如果这种精确行为是硬性要求,那么测量(JS,或一个缩放到 viewBox 的 SVG <text>)仍是唯一途径。但对于绝大多数展示标题而言,相对容器的尺寸设定看起来同样好或更好 — 且没有那些代价。
浏览器支持
容器查询长度单位(cqi 及同类)在所有当前的常青浏览器中都受支持,自 2023 年以来便是如此。clamp()、overflow-wrap、hyphens 和 text-wrap: balance 也广泛可用(text-wrap: balance 最新,在缺失处会优雅降级为普通换行)。若要在很旧的浏览器上做回退,在 clamp() 那一行之前声明一个普通的 font-size 就够了。
要点
- 人人都写的字体适配 JavaScript 之所以存在,主要是因为在容器查询出现之前,CSS 无法按容器设定字体尺寸。
container-type: inline-size+font-size: clamp(min, Ncqi, max)给出相对容器的字体,带移动端下限和桌面端上限。- 如果尺寸看起来被忽略了,那是层叠 — 通过嵌套你的规则来超过设计系统宽泛选择器的特异度。
cqi按容器宽度缩放,而非字符串长度;用overflow-wrap: break-word和hyphens: auto让长单词干净地断开而非溢出。- 当文档的
lang设定正确时,hyphens: auto会免费地按语言工作。 - 只有当你确实需要"每个字符串精确占一行"的适配时,才动用 JavaScript。
现在它已经发布 — 两个极小、无依赖的包:用于 React 的 @oleksiimazurenko/react-fit-text,以及框架无关的 CSS 核心 @oleksiimazurenko/fit-text(源码在 GitHub):
npm install @oleksiimazurenko/react-fit-textimport { FitText } from '@oleksiimazurenko/react-fit-text'
import '@oleksiimazurenko/fit-text/style.css'
// No props needed — scales to its container with sensible defaults.
<FitText>Learn anything, beautifully</FitText>