Skip to main content
返回博客
CSSContainer QueriesTypographyFrontendPerformance

用纯 CSS 让文本适配容器 — 无需 JavaScript

人人都在写一个 JavaScript hook,通过缩小 font-size 让文本塞进容器。这里有一个完全可用、零 JS 的替代方案,使用容器查询单位、clamp() 和按语言的连字符断词 — 包括让很多人放弃的层叠陷阱。

发布于 2026年7月24日11 分钟阅读

大号展示标题有个恼人的毛病:在你的设计里完美贴合的文本,一旦容器变窄、或换成另一种语言文案变长,就会溢出。多年来标准解法都是 JavaScript — 测量渲染后的文本,与其容器比较,然后不断缩小字体直到塞得下。几乎每个人都在重新发明这件事:一个自制的 useFitText hook、一个 ResizeObserver 循环,或者像 fittytextFitreact-textfit 这样的库。

本文展示一个完全可用、零 JavaScript 的替代方案:借助 CSS 容器查询单位让字体随其容器缩放,再加上带连字符的正确断行。没有 hook,没有测量,水合后也没有布局跳动。

人人都在写的那段 JavaScript

套路总是一样。先用某个基准尺寸渲染文本,读取文本宽度和其父元素宽度,如果文本更宽,就减小字号再试一次。一个最小版本是这样:

useFitText.ts
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.css
.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-wraphyphenstext-wrap: balance 也广泛可用(text-wrap: balance 最新,在缺失处会优雅降级为普通换行)。若要在很旧的浏览器上做回退,在 clamp() 那一行之前声明一个普通的 font-size 就够了。

要点

  • 人人都写的字体适配 JavaScript 之所以存在,主要是因为在容器查询出现之前,CSS 无法按容器设定字体尺寸。
  • container-type: inline-size + font-size: clamp(min, Ncqi, max) 给出相对容器的字体,带移动端下限和桌面端上限。
  • 如果尺寸看起来被忽略了,那是层叠 — 通过嵌套你的规则来超过设计系统宽泛选择器的特异度。
  • cqi 按容器宽度缩放,而非字符串长度;用 overflow-wrap: break-wordhyphens: auto 让长单词干净地断开而非溢出。
  • 当文档的 lang 设定正确时,hyphens: auto 会免费地按语言工作。
  • 只有当你确实需要"每个字符串精确占一行"的适配时,才动用 JavaScript。

现在它已经发布 — 两个极小、无依赖的包:用于 React 的 @oleksiimazurenko/react-fit-text,以及框架无关的 CSS 核心 @oleksiimazurenko/fit-text源码在 GitHub):

npm install @oleksiimazurenko/react-fit-text
import { 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>