自适应字体与间距

张玥 2026年9月18日 阅读时间 30分钟
响应式设计 流体排版 clamp() 容器查询 设计令牌
CSS高级技巧响应式设计之自适应字体与间距

过去十年,我们把大量精力花在“让布局流动”上:Flexbox、Grid、媒体查询、断点表。但真正决定一页文字读起来舒不舒服的,往往不是栅格,而是两件最不起眼的事——字多大、空多少。字号写死 16px,在小屏上拥挤、在大屏上空旷;间距写死 20px,在手机上是奢侈、在 4K 屏上是寒酸。响应式设计的最后一公里,其实是排版的自适应。这篇 30 分钟的长文,从单位体系讲到 clamp() 的数学原理,从容器查询讲到模块化比例,从间距系统讲到设计令牌,并配有五个可实时交互的实验台——你可以亲手拖动滑块、改一改参数,看字号与间距如何在视口中连续流动。

为什么自适应排版是响应式设计的最后一公里

大多数人接触响应式设计,是从“手机上一列、桌面端三列”开始的。栅格解决了“东西放在哪里”,却没有解决“东西长什么样子”。于是一个很常见的现象出现了:布局很响应式,阅读体验却很糟糕。

设想一个典型的卡片标题,设计稿定在 font-size: 24px:

  • 在 320px 宽的小屏上,24px 的标题一行只能放下 8 个汉字,标题被迫折成三行,把正文挤到屏幕之外;
  • 在 2560px 宽的大屏上,24px 的标题夹在 1200px 宽的正文里,视觉上“缩”成了一句话,层级感荡然无存。

传统解法是写媒体查询:

/* ❌ 断点式跳变:在 767px 与 768px 之间,字号会突然跳一下 */
.title { font-size: 20px; }
@media (min-width: 768px) {
  .title { font-size: 24px; }
}
@media (min-width: 1200px) {
  .title { font-size: 32px; }
}

/* 问题:需要维护 N 个断点 × M 个文本层级 = N×M 条规则 */
/* 而且断点之间是一段“死区”,字号在那里纹丝不动 */

这种方案的三个致命伤:

  1. 跳变:用户拖动窗口时,字号会在断点处“咔”地跳一下,观感割裂。
  2. 组合爆炸:10 个文本层级 × 5 个断点 = 50 条规则,改一次设计稿要动 50 个地方。
  3. 死区:断点之间的区间里,字号完全不变化,无法真正贴合视口。

流体排版:让数值连续变化

流体排版(Fluid Typography)的核心思想只有一句话:把字号表达成视口宽度的函数,而不是若干个离散常量。在数学上,它就是一条被上下限截断的直线:

/* 一般形式 */
font-size = clamp(最小值, 斜率 × 视口宽度 + 截距, 最大值)

/* 具体例子:320px 时 16px,1440px 时 24px */
font-size = clamp(1rem, 0.55rem + 1.4vw, 1.5rem)

/* 视口 320px → 16px */
/* 视口 880px → 20px */
/* 视口 1440px → 24px */
/* 视口 2400px → 仍然是 24px(被上限截断) */

一行代码,替代了所有断点。这就是本篇要讲透的东西。

单位体系全景:选对单位,等于成功了一半

在写任何自适应排版代码之前,必须先想清楚一件事:这个数值应该相对于什么变化?CSS 提供的单位,本质上就是不同的“参照系”。选错参照系,后面做再多优化都是徒劳。

单位 相对参照 适用场景 注意事项
px 设备像素(逻辑) 边框、细线、不需要缩放的尺寸 谨慎 不随用户字号设置缩放
em 当前元素的 font-size 组件内部的相对间距、内边距 危险 会逐层复合,极易失控
rem 根元素 html 的 font-size 字号、间距、圆角、整体缩放 推荐 全站一致,可被用户覆盖
% 父元素的同名属性 宽度、行高、字号继承 一般 语义不如 em/rem 明确
vw / vh 视口宽 / 高的 1% 流体排版的斜率、全屏区块 谨慎 滚动条宽度会造成偏差
vmin / vmax 视口宽高中较小 / 较大的那个 横竖屏都能用的缩放 推荐 适合全屏标题
ch “0”字形的宽度 控制文本行长(measure) 推荐 排版神器,见后文
lh / rlh 当前 / 根元素的行高 与行高成比例的垂直间距 新 支持度逐年提升
cqw / cqi 容器宽度的 1%(inline 方向) 组件级自适应 推荐 需要 container-type
dvh / svh / lvh 动态 / 最小 / 最大视口高度 移动端全屏区块 推荐 解决地址栏跳动问题

一条简单但有效的选型规则

/* 规则一:文字与间距,用 rem */
.card {
  font-size: 1rem;
  padding: 1.5rem;
  border-radius: 0.5rem;
}

/* 规则二:组件内部比例,用 em */
.btn {
  font-size: 1rem;
  padding: 0.6em 1.2em; /* 跟随自身字号 */
  gap: 0.5em;
}
.btn.small { font-size: 0.85rem; }
/* 只需改 font-size,内边距自动等比缩小 */

/* 规则三:视觉参照视口时,才用 vw/vh */
.hero-title {
  font-size: clamp(2rem, 5vw, 4.5rem);
}

/* 规则四:宽度不用 %,用 ch 或 fr */
.prose { max-width: 68ch; }
rem 字号、间距、圆角的默认选择
em 组件内部的等比缩放,别嵌套超过两层
vw / cqw 只在表达“随视口连续变化”时使用
px 只留给 1px 边框、阴影偏移这类不需要缩放的细节

em 的复合陷阱:为什么它总是失控

em 的参照物是“当前元素的 font-size”,而 font-size 本身又是可继承的。这两个事实叠加起来,就产生了指数级复合:每一层都乘以一次自己的系数。

下面的对比实验台可以拖动滑块调整父级字号,观察 em 与 rem 在两层嵌套后的差异:

em —— 每层都乘以 1.5
父级 16.0px
1.5em → 24.0px
再 1.5em → 36.0px
rem —— 永远基于根字号
父级 16.0px
1.5rem → 24.0px
再 1.5rem → 24.0px

左侧的方块会随着父级字号整体放大,而内部文本还会再放大一次——这就是 em 的复合效应。右侧的 rem 则始终保持稳定的层级关系。

什么时候该用 em

并不是说 em 不能用,而是要理解它的语义:em 表达的是“相对于我自己”。当一个组件的所有内部尺寸都应该随它自己的字号等比缩放时,em 就是正确的工具。

/* ✅ em 的正确用法:组件级等比缩放 */
.badge {
  font-size: 0.875rem;
  padding: 0.25em 0.75em;
  border-radius: 1em;
  gap: 0.35em;
}
.badge.lg { font-size: 1.125rem; }
/* 只改一行,整个徽章等比放大 */

/* ❌ em 的典型误用:嵌套列表 */
ul { font-size: 1.1em; }
/* 三层嵌套后:1.1³ = 1.331 倍 —— 深层的字明显偏大 */

/* ✅ 修正:嵌套结构用 rem */
ul { font-size: 1.1rem; }
em 会失控
深层嵌套(列表、菜单、树形结构)中,em 会指数放大。改一个父级字号,可能让整棵子树的尺寸全部错位。
rem 更可控
全站只有一个参照物(根字号),层级关系稳定,也方便用户通过浏览器字号设置统一缩放。

流体排版的核心:clamp() 与线性插值

掌握了单位,就可以进入本篇的正题。clamp() 是流体排版的主力函数,它的签名是 clamp(最小值, 首选值, 最大值),返回值被限制在上下限之间。

/* clamp(MIN, VAL, MAX) 等价于 */
max(MIN, min(VAL, MAX))

/* 三个参数都可以是任意表达式 */
font-size: clamp(1rem, 0.5rem + 2vw, 2rem);

/* 首选值里用 calc 也可以,但不写 calc 更简洁 */
font-size: clamp(1rem, calc(0.5rem + 2vw), 2rem);
/* 上面两行完全等价 —— clamp 内部会自动做数学运算 */

怎么求出中间那个表达式?

假设你希望:视口 360px 时字号 16px,视口 1440px 时字号 28px。这是一个两点确定一条直线的问题。

/* 第一步:求斜率(每 1px 视口宽度,字号变化多少)*/
斜率 = (28 - 16) / (1440 - 360) = 12 / 1080 ≈ 0.01111

/* 第二步:把“每 px 视口”换算成 vw */
/* 1vw = 视口宽度的 1%,所以 0.01111px/px = 1.111px/vw */
vw 系数 = 0.01111 × 100 ≈ 1.111vw

/* 第三步:求截距(把 vw 项代入任意一个端点)*/
/* 在 360px 处:1.111vw = 1.111 × 3.6 = 4px */
截距 = 16 - 4 = 12px = 0.75rem

/* 最终公式 */
font-size: clamp(1rem, 0.75rem + 1.111vw, 1.75rem);

/* 验算 */
/* 360px → 0.75×16 + 1.111×3.6 = 12 + 4 = 16px ✓ */
/* 1440px → 0.75×16 + 1.111×14.4 = 12 + 16 = 28px ✓ */

下面这个实验台把这条曲线画了出来。拖动滑块改变模拟视口宽度,你会看到圆点沿着曲线移动;切换预设,可以看到不同“激进程度”的排版策略。

自适应字号预览

拖动上面的滑块,这段文字的字号会随着“视口”连续变化,而不是在断点处突然跳变。

几个常见误区

  • 忘了下限:只写 2vw 而不套 clamp(),在 320px 的手机上字号会小到看不清。
  • 上下限倒置:clamp(2rem, ..., 1rem) 是无效的,最小值必须小于最大值。
  • 把 vw 用在纯文本容器上:视口宽 2000px 时,5vw 就是 100px 的字,读起来像标语而不是正文。
  • 忘了滚动条:桌面端有滚动条时,100vw 会比可视宽度多出约 15px,可能造成横向滚动。
反例
font-size: 3vw;
在 320px 屏幕上只有 9.6px,几乎无法阅读;在 2560px 屏幕上又变成 76.8px,大得荒谬。
正解
font-size: clamp(1rem, 0.75rem + 1.1vw, 1.75rem);
小屏保底 16px,大屏封顶 28px,中间连续过渡。

容器查询与 cqw:让组件自己决定怎么缩放

clamp() 配合 vw 解决的是“页面级”的流体排版。但现代前端页面是由可复用组件拼装的:同一个卡片组件,可能出现在 1200px 宽的主栏里,也可能出现在 280px 宽的侧边栏里。此时视口宽度完全不能反映组件所处的环境。

容器查询(Container Queries)解决的正是这个问题:让元素根据它所在的容器的尺寸来调整自己。

/* 第一步:声明一个查询容器 */
.card {
  container-type: inline-size;
  container-name: card; /* 可选,便于精确定位 */
}

/* 第二步:用 cqw 表达“相对于容器宽度” */
.card h4 {
  font-size: clamp(0.95rem, 5cqw, 1.5rem);
}

/* 第三步:用 @container 做布局切换 */
@container card (min-width: 380px) {
  .card__inner { flex-direction: row; }
  .card__thumb { width: 104px; height: auto; }
}

/* 可用单位:cqw / cqh / cqi / cqb / cqmin / cqmax */
/* cqw = 容器宽度的 1%,cqi = 容器 inline 方向的 1%(推荐)*/

下面这个演示中的卡片,宽度由滑块控制。注意观察:当卡片变宽时,缩略图从“上方横条”变成“左侧竖条”,字号也同步放大——这一切都发生在同一个视口宽度下。

容器查询卡片

我的字号由容器宽度决定,而不是视口宽度。把滑块拖到 380px 以上,布局会从纵向切换为横向。

font-size: clamp(0.95rem, 5cqw, 1.5rem)

提示:@container 只能作用于容器内部的后代元素,无法给容器自身设置样式。所以布局切换写在了 .cq-inner 上,而不是 .cq-card 上。

容器查询单位速查

单位 参照 典型用途
cqw 容器宽度的 1% 水平方向的流体尺寸
cqh 容器高度的 1% 需要固定高度的容器内部布局
cqi 容器 inline 方向尺寸的 1% 推荐 自动适配书写方向
cqb 容器 block 方向尺寸的 1% 纵向书写或特殊排版
cqmin cqi 与 cqb 中较小的 保证任何比例下都不过大
cqmax cqi 与 cqb 中较大的 需要“撑满”效果时使用

实践中建议优先使用 cqi,因为它在横排与竖排书写模式下都能给出符合直觉的结果,是真正意义上的“逻辑宽度单位”。

模块化比例:让字号阶梯有节奏

只有 clamp() 还不够。一页内容里通常有 6~8 个文本层级:正文、辅助文字、标题 1~4、超大标题……如果每一级都单独调参,最终一定会失控。

音乐里的音阶由固定比例决定,排版里的字号阶梯也可以。模块化比例(Modular Scale)的核心思想是:选一个基准字号和一个比例,用指数运算生成整条阶梯。

/* 第 n 级的字号 = 基准字号 × 比例的 n 次方 */
size(n) = base × ration

/* 常用比例及其音乐学名称 */
1.067 —— 小二度,阶梯非常密集
1.125 —— 大二度,适合信息密集的界面
1.200 —— 小三度,最通用的选择
1.250 —— 大三度,层级分明,适合营销页
1.333 —— 纯四度,张力强,适合大标题
1.500 —— 纯五度,非常夸张,慎用

/* 例:base = 16px,ratio = 1.25 */
xs   = 16 × 1.25⁻¹ = 12.80px
base = 16 × 1.25⁰  = 16.00px
lg   = 16 × 1.25¹  = 20.00px
xl   = 16 × 1.25²  = 25.00px
2xl  = 16 × 1.25³  = 31.25px
3xl  = 16 × 1.25⁴  = 39.06px

下面的实验台可以切换比例和基准字号,实时观察整条阶梯的变化。切换比例时,重点感受“层级的疏密感”——这比记住数字重要得多。

在流体排版里使用模块化比例

把比例与 clamp() 结合,就能得到一套真正自适应的字号系统:

/* 定义比例与基准 */
:root {
  --ratio: 1.25;
  --base-min: 1rem;
  --base-max: 1.125rem;
}

/* 每一级都用同一套上下限策略,保证整体和谐 */
h1 {
  font-size: clamp(2rem, 1.2rem + 3.2vw, 3.5rem);
}
h2 {
  font-size: clamp(1.6rem, 1.1rem + 2.1vw, 2.6rem);
}
h3 {
  font-size: clamp(1.3rem, 1rem + 1.2vw, 1.9rem);
}
h4 {
  font-size: clamp(1.1rem, 0.95rem + 0.6vw, 1.4rem);
}
body {
  font-size: clamp(1rem, 0.94rem + 0.3vw, 1.125rem);
}
small {
  font-size: clamp(0.8rem, 0.76rem + 0.2vw, 0.9rem);
}

/* 关键:越大的标题,vw 系数越大 → 在大屏上放大得越明显 */
/* 这样既保持了层级比例,又让大标题在大屏上有存在感 */
正文 vw 系数最小(0.2~0.3),保证阅读舒适,不被视口牵着走
中标题 vw 系数中等(0.6~1.2),随屏幕温和放大
大标题 vw 系数最大(2~3.5),在大屏上有视觉冲击力
辅助文字 几乎不随视口变化,保持稳定的小字节奏

间距系统:从 8pt 网格到流体间距

字体解决了“读得清”,间距解决了“看得舒服”。排版界最广为接受的经验是 8pt 网格:所有间距都是 8 的整数倍。它的好处是让所有元素在视觉上对齐到同一个节奏上。

下面的实验台可以调整基础单位(4px / 8px / 12px),观察整条间距阶梯的变化:

用乘法而非硬编码

/* ✅ 用 calc 生成间距阶梯 */
:root {
  --space-unit: 0.5rem; /* 8px */
  --space-1: calc(var(--space-unit) * 1);  /* 8px */
  --space-2: calc(var(--space-unit) * 2);  /* 16px */
  --space-3: calc(var(--space-unit) * 3);  /* 24px */
  --space-4: calc(var(--space-unit) * 4);  /* 32px */
  --space-6: calc(var(--space-unit) * 6);  /* 48px */
  --space-8: calc(var(--space-unit) * 8);  /* 64px */
  --space-12: calc(var(--space-unit) * 12); /* 96px */
}

/* 使用时语义清晰 */
.card { padding: var(--space-4); }
.card__title { margin-bottom: var(--space-2); }
.section { padding-block: var(--space-12); }
4px 图标与文字、按钮内边距等极小间隙
8px 同一组内的元素间距(默认节奏)
16~24px 卡片内边距、组件之间的间隔
48~96px 区块之间的呼吸空间

间距也要流体:clamp 化的区块留白

和字号一样,间距在大屏上也需要放大。最典型的场景是页面区块的上下留白:手机上 48px 已经很宽敞,桌面端 48px 却显得局促。

/* ❌ 固定留白:大屏上显得拥挤 */
.section { padding-block: 48px; }

/* ✅ 流体留白:小屏 48px,大屏 96px */
.section {
  padding-block: clamp(3rem, 1.5rem + 6.7vw, 6rem);
}

/* ✅ 卡片内边距:小屏 16px,大屏 32px */
.card {
  padding: clamp(1rem, 0.5rem + 2.2vw, 2rem);
}

/* ✅ 栅格间距:小屏 16px,大屏 32px */
.grid {
  display: grid;
  gap: clamp(1rem, 0.5rem + 2.2vw, 2rem);
}

/* ✅ 用 min() 控制最大值,避免大屏上留白过多 */
.prose {
  padding-inline: min(5vw, 3rem);
}

gap 优于 margin 的三个理由

  1. 不产生外边距折叠:margin 的折叠规则复杂且容易出意外,gap 永远不会。
  2. 不参与“最后一个元素”的取舍:用 margin-bottom 时总要写 :last-child { margin: 0 }。
  3. 语义更清晰:gap 明确表达“元素之间的距离”,而不是“这个元素向外推的距离”。
margin 方案
.item { margin-bottom: 16px; }
.item:last-child { margin-bottom: 0; }
还需要额外处理折叠、清浮动等历史遗留问题。
gap 方案
.list { display: flex; flex-direction: column; gap: 1rem; }
一行搞定,没有折叠、没有最后一个元素的特判。

行高与行长:决定“好不好读”的两个隐藏参数

字号调对了,文字依然可能难读。真正决定阅读体验的,是三个参数共同作用的结果:字号、行高、行长。它们的关系可以用一句话概括:行越长,行高越大;字越大,行高越小。

行长:用 ch 控制

ch 是“数字 0 的宽度”,在大多数字体里约等于半个汉字宽度。排版学的研究结论是:正文行长在 45~75 个字符之间最舒适,也就是大约 66ch。

/* ✅ 用 ch 控制阅读宽度,而不是 px */
.prose {
  max-width: 68ch;
  margin-inline: auto;
}

/* 好处:无论字号怎么变,行长始终是 68 个字符 */
/* 如果用 max-width: 720px,字号变大后行长就变成了 50 个字符 */

/* 中文正文可以适当调小,因为汉字更“方” */
.prose-zh { max-width: 38em; }
/* 或者 */
.prose-zh { max-width: min(68ch, 38em); }

行高:用无单位数值

/* ❌ 用 px 或 %:子元素继承的是“计算后的值” */
.parent {
  font-size: 20px;
  line-height: 30px;
}
.child {
  font-size: 40px;
  /* 继承的是 30px,而不是 1.5 —— 行高严重偏小! */
}

/* ✅ 用无单位数值:子元素重新计算 */
.parent {
  font-size: 20px;
  line-height: 1.5;
}
.child {
  font-size: 40px;
  /* 继承的是 1.5,实际行高 60px —— 正确! */
}

/* 推荐的行高区间 */
正文:1.5 ~ 1.7
标题:1.1 ~ 1.3
大标题:0.95 ~ 1.1
辅助文字:1.4 ~ 1.5
px 行高 继承计算值,子元素字号变大时行高不跟随
% 行高 同样继承计算值,等价于 px
无单位 继承比例,子元素自动重算 —— 唯一正确解
标题行高 大字号需要更紧凑的行高,否则显得松散

字号与行高的联动

随着字号在视口中变大,行高的相对值应该略微减小。原因很直观:字越大,字面上的视觉重心越大,过大的行距会让段落显得散架。现代 CSS 可以用 lh 单位或直接对 line-height 使用 clamp():

/* 正文行高随字号反向微调 */
body {
  font-size: clamp(1rem, 0.94rem + 0.3vw, 1.125rem);
  line-height: clamp(1.6, 1.75 - 0.1vw, 1.72);
}

/* 标题用更紧的行高 */
h2 {
  font-size: clamp(1.6rem, 1.1rem + 2.1vw, 2.6rem);
  line-height: 1.15;
  text-wrap: balance; /* 让多行标题长短均衡 */
}

/* 垂直间距用 lh 单位,与行高严格对齐 */
.article p + p {
  margin-top: 0.75lh;
}

text-wrap: balance 与 text-wrap: pretty 是近两年非常值得关注的新特性。前者让标题的多行长度更均匀,后者避免段落最后一行只留一个孤字。它们几乎零成本,却能显著提升排版的精致度。

设计令牌:把排版规则沉淀成一套变量

到这里,我们已经有了字号阶梯、间距阶梯、行高规则、缓动曲线。如果它们散落在几百行 CSS 里,很快就会失控。设计令牌(Design Tokens)就是把这些决策集中到一处的方式。

/* ===== 第 1 层:原始值(Primitive Tokens)===== */
:root {
  /* 字号刻度 */
  --fs-100: 0.75rem;
  --fs-200: 0.875rem;
  --fs-300: 1rem;
  --fs-400: 1.25rem;
  --fs-500: 1.5rem;
  --fs-600: 2rem;
  --fs-700: 2.5rem;
  --fs-800: 3.5rem;

  /* 间距刻度(8pt 网格)*/
  --sp-1: 0.25rem;
  --sp-2: 0.5rem;
  --sp-3: 0.75rem;
  --sp-4: 1rem;
  --sp-6: 1.5rem;
  --sp-8: 2rem;
  --sp-12: 3rem;
  --sp-16: 4rem;
}

/* ===== 第 2 层:语义令牌(Semantic Tokens)===== */
:root {
  --text-body: clamp(var(--fs-300), 0.94rem + 0.3vw, 1.125rem);
  --text-small: clamp(var(--fs-200), 0.82rem + 0.2vw, 0.95rem);
  --text-h3: clamp(var(--fs-500), 1rem + 1.2vw, 1.9rem);
  --text-h2: clamp(var(--fs-600), 1.1rem + 2.1vw, 2.6rem);
  --text-h1: clamp(var(--fs-700), 1.2rem + 3.2vw, 3.5rem);

  --space-inline: clamp(var(--sp-4), 3vw, var(--sp-8));
  --space-block: clamp(var(--sp-8), 1.5rem + 6.7vw, var(--sp-16));
  --gap-grid: clamp(var(--sp-4), 2vw, var(--sp-8));
}

/* ===== 第 3 层:组件令牌 ===== */
.card {
  --card-padding: clamp(var(--sp-4), 2.2vw, var(--sp-8));
  --card-radius: clamp(6px, 0.8vw, 16px);
  padding: var(--card-padding);
  border-radius: var(--card-radius);
}

/* 使用层:只引用语义令牌,不碰原始值 */
.article h1 { font-size: var(--text-h1); }
.article p  { font-size: var(--text-body); }
.section  { padding-block: var(--space-block); }

为什么一定要分层

  • 原始层只回答“有哪些刻度”,不关心用在哪里。改刻度不会影响语义。
  • 语义层回答“这个刻度用在哪”。想让正文整体变大 5%,只需要改 --text-body 一处。
  • 组件层回答“这个组件内部的微调”。换主题、换密度时,只需要覆盖组件层。

分层的最大价值在于变更的局部化。当设计说“整体字号偏小,调大一点”时,你应该只需要改一行,而不是全站搜索替换。

自适应排版的十个经典陷阱

这些坑几乎每一个响应式项目都会踩到,提前知道能省下大量调试时间。

陷阱 表现 解法
纯 vw 字号 小屏上字小到看不清 永远用 clamp() 包住 vw
100vw 溢出 桌面端出现横向滚动条 改用 100%,或 calc(100vw - var(--sbw))
px 行高继承 子元素字号变大后行高不变 一律使用无单位行高
em 深层嵌套 第三层以后尺寸失控 嵌套结构改用 rem
移动端 100vh 地址栏收起时内容被裁切 改用 100dvh / 100svh
字号写死在 html 上 用户浏览器字号设置失效 保持 html { font-size: 100% }
媒体查询断点过多 规则爆炸、难以维护 优先用 clamp() 与容器查询
忽略中文的字符宽度 按英文字符数估算的行长偏窄 中文用 em 或乘以 0.5 系数
标题行高与正文相同 大标题显得松散无力 标题使用 1.1~1.3 的紧凑行高
只在大屏上测试 低端真机上排版错位 务必在真实手机上验证

关于 100vw 与滚动条

/* 问题:桌面端有滚动条时,100vw 包含了滚动条宽度 */
/* 结果:页面比可视区域宽约 15px,出现横向滚动 */
.full { width: 100vw; } /* ❌ */

/* ✅ 方案一:用 % 代替 vw */
.full { width: 100%; }

/* ✅ 方案二:手动扣除滚动条宽度 */
:root {
  --sbw: calc(100vw - 100%);
}
.full { width: calc(100vw - var(--sbw)); }

/* ✅ 方案三:scrollbar-gutter 预留空间 */
html {
  scrollbar-gutter: stable;
}

关于移动端的 100vh

移动浏览器为了隐藏地址栏,会在滚动时改变视口高度。传统 100vh 用的是“最大可能高度”,导致内容底部被裁切。现代解法是使用新的视口单位:

/* 三种视口高度单位的区别 */
100svh /* small:地址栏展开时的最小高度(最安全)*/
100lvh /* large:地址栏收起时的最大高度 */
100dvh /* dynamic:随地址栏实时变化的高度 */

/* 全屏首屏区块:用 svh 保证内容不被裁 */
.hero {
  min-height: 100svh;
}

/* 退化方案:老浏览器用 vh 兜底 */
.hero {
  min-height: 100vh;
  min-height: 100svh;
}

无障碍:不要与用户的缩放意图对抗

自适应排版有一个容易被忽视的伦理维度:用户可能已经调整过浏览器的默认字号。视力不佳的用户会把浏览器默认字号从 16px 调到 20px 甚至 24px。如果你的排版系统忽略这个设置,就等于剥夺了他们的阅读能力。

/* ✅ 正确:让根字号跟随用户的浏览器设置 */
html {
  font-size: 100%; /* 等价于 1rem = 用户的默认字号 */
}

/* ❌ 错误:写死根字号,覆盖用户设置 */
html { font-size: 16px; }

/* ❌ 更糟:禁止缩放 */
/* <meta name="viewport" content="width=device-width, user-scalable=no"> */
/* 这是明确的 WCAG 违规,现代浏览器甚至会直接忽略它 */

/* ✅ 所有字号与间距都用 rem,自动跟随用户设置 */
.card {
  font-size: 1rem;
  padding: 1.5rem;
  gap: 1rem;
}

流体排版与缩放的关系

有一点必须注意:clamp() 里的 vw 部分不随用户字号设置缩放。因为 vw 的参照物是视口,而不是根字号。这会导致一个微妙的问题:当用户把默认字号调大到 24px 时,clamp(1rem, 0.75rem + 1.1vw, 1.75rem) 的上下限变大了,但中间那段的 vw 部分没变。

/* 用户默认 16px,视口 1440px */
/* clamp(16, 12 + 16, 28) = 28px */

/* 用户默认 24px,同样的视口 */
/* clamp(24, 18 + 16, 42) = 34px —— 变大了,但没完全等比 */

/* ✅ 缓解方案:让 vw 项的占比小一些,rem 项占比大一些 */
/* 这样用户缩放时,整体更接近等比放大 */
font-size: clamp(1rem, 0.85rem + 0.6vw, 1.5rem);
/* 对比激进版:clamp(1rem, 0.3rem + 3vw, 2.5rem) */
禁止 在 html 上写死 font-size: 16px
禁止 user-scalable=no 或 maximum-scale=1
推荐 正文最小字号不低于 1rem(16px)
推荐 正文与背景的对比度至少 4.5:1(WCAG AA)

另外,行长的自适应也需要注意:当用户放大字号时,如果容器宽度是固定的像素值,行长会变短、行数会变多。用 ch 或 em 约束行宽,能自动适应字号变化,这是 ch 相对 px 的最大优势。

实战:一套可直接落地的流体排版系统

把前面所有知识点整合起来,下面是一套完整的、可以直接复制到项目中的流体排版系统。它包含四个部分:重置、令牌、排版规则、间距规则。

/* =================================================== */
/* 第一部分:基础重置 */
/* =================================================== */
html {
  font-size: 100%; /* 尊重用户设置 */
  -webkit-text-size-adjust: 100%;
  scrollbar-gutter: stable;
}

body {
  font-family: system-ui, -apple-system, 'Segoe UI', sans-serif;
  font-size: var(--text-body);
  line-height: 1.65;
  text-rendering: optimizeLegibility;
}

/* =================================================== */
/* 第二部分:排版与间距令牌 */
/* =================================================== */
:root {
  /* —— 字号 —— */
  --text-xs:   clamp(0.75rem, 0.73rem + 0.1vw, 0.8rem);
  --text-sm:   clamp(0.875rem, 0.84rem + 0.18vw, 0.95rem);
  --text-body: clamp(1rem, 0.94rem + 0.3vw, 1.125rem);
  --text-lead: clamp(1.125rem, 1rem + 0.6vw, 1.35rem);
  --text-h4:   clamp(1.15rem, 0.98rem + 0.85vw, 1.45rem);
  --text-h3:   clamp(1.35rem, 1.02rem + 1.65vw, 1.9rem);
  --text-h2:   clamp(1.6rem, 1.05rem + 2.7vw, 2.5rem);
  --text-h1:   clamp(2rem, 1.1rem + 4.5vw, 3.5rem);
  --text-hero: clamp(2.5rem, 1rem + 7.5vw, 5rem);

  /* —— 行高 —— */
  --lh-tight: 1.1;
  --lh-snug:  1.3;
  --lh-base:  1.65;
  --lh-loose: 1.8;

  /* —— 间距(8pt 网格 + 流体)—— */
  --sp-1: 0.25rem;
  --sp-2: 0.5rem;
  --sp-3: 0.75rem;
  --sp-4: 1rem;
  --sp-6: 1.5rem;
  --sp-8: 2rem;
  --sp-12: 3rem;
  --sp-16: 4rem;

  /* —— 语义间距 —— */
  --gap-inline:  clamp(var(--sp-4), 3vw, var(--sp-8));
  --gap-block:   clamp(var(--sp-8), 1.5rem + 6vw, var(--sp-16));
  --gap-grid:    clamp(var(--sp-4), 2vw, var(--sp-8));
  --radius-card: clamp(0.375rem, 0.8vw, 1rem);
}

/* =================================================== */
/* 第三部分:排版规则 */
/* =================================================== */
h1, h2, h3, h4 {
  line-height: var(--lh-snug);
  letter-spacing: -0.01em;
  text-wrap: balance;
  margin-block-end: 0.5em;
}

h1 {
  font-size: var(--text-h1);
  line-height: var(--lh-tight);
}
h2 { font-size: var(--text-h2); }
h3 { font-size: var(--text-h3); }
h4 { font-size: var(--text-h4); }

p, li {
  line-height: var(--lh-base);
  text-wrap: pretty;
  max-width: 68ch;
}

/* =================================================== */
/* 第四部分:间距与布局 */
/* =================================================== */
.container {
  width: min(100% - 2 * var(--gap-inline), 72rem);
  margin-inline: auto;
}

.section {
  padding-block: var(--gap-block);
}

.grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(18rem, 100%), 1fr));
  gap: var(--gap-grid);
}

.card {
  padding: clamp(var(--sp-4), 2.4vw, var(--sp-8));
  border-radius: var(--radius-card);
  contain: layout paint; /* 隔离重绘范围 */
}

这套系统解决了什么

  • 全站只有一个参照系:所有尺寸都是 rem,跟随用户设置缩放。
  • 字号阶梯连续:从 xs 到 hero,八级字号都有平滑的流体曲线。
  • 间距有节奏:底层 8pt 网格,上层流体插值,小屏紧凑、大屏舒展。
  • 行长自适应:用 ch 约束,字号变大时行长自动保持舒适的字符数。
  • 可维护:改一个令牌,全站同步变化。

调试与验证:怎么确认真的做对了

响应式排版的问题往往“看起来没问题”,但在某些特定宽度下会突然崩坏。系统化的验证流程能帮你提前发现它们。

/* ===== 排查流程:五步法 ===== */

/* Step 1:连续拖动窗口,而不是跳跃式缩放 */
/* 在 320 → 1600 之间缓慢拖动,观察是否有跳变 */
/* 如果字号在某处突然变了一下,说明还有断点残留 */

/* Step 2:DevTools 中开启 Computed 面板 */
/* 选中元素 → Computed → 找到 font-size */
/* 它会告诉你最终计算出的像素值,以及来源规则 */

/* Step 3:用 Rendering 面板的“文字渲染统计” */
/* 部分浏览器会提示字号过小的文本 */

/* Step 4:Lighthouse 的可访问性审计 */
/* 会检查:字号是否过小、对比度是否足够、是否允许缩放 */

/* Step 5:真机测试(最关键的一步)*/
/* 模拟器无法反映真实的字体渲染差异与 DPR 影响 */
/* 至少覆盖:小屏安卓、iPhone SE 尺寸、iPad、桌面宽屏 */
320px 最窄的主流视口,检查是否有内容溢出
375px iPhone 标准宽度,检查正文可读性
768px 平板竖屏,检查布局切换点
1440px 桌面主流宽度,检查上限是否生效
2560px 超宽屏,检查行长是否过长

一个实用的自检清单

  1. 把浏览器默认字号调到 24px,页面是否还能正常阅读?
  2. 把窗口从 320px 缓慢拖到 1600px,字号是否有跳变?
  3. 在 2560px 屏幕上,正文行长是否仍然在 45~75 字符之间?
  4. 缩放到 200%,是否有内容被裁切或重叠?
  5. 标题在大屏上是否有足够的视觉权重,而不是“缩成一团”?
  6. 卡片、列表项之间的间距是否保持了一致的节奏感?

兼容性与渐进增强

本篇涉及的现代 CSS 特性支持度差异较大,需要一套明确的降级策略。

/* 兼容性速查(截至 2026 年)*/
/* rem / em / % —— 全平台,无风险 */
/* vw / vh / vmin / vmax —— 全平台 */
/* clamp() —— Chrome 79+ / Safari 13.1+ */
/* min() / max() —— 同上 */
/* ch 单位 —— 全平台 */
/* gap(Flex 布局) —— Chrome 84+ / Safari 14.1+ */
/* container queries —— Chrome 105+ / Safari 16+ */
/* cqw / cqi 单位 —— 同上 */
/* dvh / svh / lvh —— Chrome 108+ / Safari 15.4+ */
/* text-wrap: balance —— Chrome 114+ / Safari 17.5+ */
/* lh / rlh 单位 —— Chrome 109+ / Safari 16.4+ */

/* 降级策略一:先给旧值,再覆盖新值 */
.card {
  font-size: 18px;  /* 老浏览器 */
  font-size: clamp(1rem, 0.9rem + 0.5vw, 1.25rem); /* 现代浏览器 */
}

/* 降级策略二:@supports 条件增强 */
.prose { max-width: 45em; }
@supports (width: 1ch) {
  .prose { max-width: 68ch; }
}

/* 降级策略三:容器查询的 JS 兜底 */
if (!CSS.supports('container-type', 'inline-size')) {
  /* 用 ResizeObserver 手动切换类名 */
  const ro = new ResizeObserver((entries) => {
    entries.forEach((e) => {
      e.target.classList.toggle('is-wide', e.contentRect.width >= 380);
    });
  });
  document.querySelectorAll('.cq-card').forEach((el) => ro.observe(el));
}
基础排版
99%+

rem / em / % / px

流体函数
97%+

clamp / min / max

容器查询
88%+

container-type / cqw

动态视口
90%+

dvh / svh / lvh

实践建议:基础排版用最保守的特性,高级手感用新特性渐进增强。即使容器查询不被支持,卡片也应该能以纵向布局正常工作——只是失去了自动切换方向的“智能感”而已。

自适应字体与间距最佳实践清单

把全文结论浓缩成一份可以贴在工位上的清单。

  • 字号与间距一律用 rem:全站一个参照系,跟随用户浏览器设置。
  • 组件内部的等比缩放用 em:但嵌套不要超过两层。
  • 流体字号必用 clamp():永远不要让裸的 vw 直接控制字号。
  • 越大的层级,vw 系数越大:正文几乎不随视口变,标题明显放大。
  • 行长用 ch 控制:正文 45~75 字符,中文可适当调小。
  • 行高一律用无单位数值:避免子元素继承计算后的像素值。
  • 标题行高比正文紧凑:大标题用 1.0~1.3,正文用 1.5~1.7。
  • 间距基于 8pt 网格:用 calc() 从基础单位推导整条阶梯。
  • 区块留白也要流体化:小屏紧凑、大屏舒展,用 clamp() 插值。
  • 用 gap 替代 margin:避免折叠,避免 :last-child 特判。
  • 组件级自适应用容器查询:不要用视口宽度去推断组件所处的环境。
  • 设计令牌分三层:原始值 → 语义值 → 组件值,变更才可控。
  • 不要在 html 上写死字号:保留 100%,尊重用户的缩放意图。
  • 永远不要禁止缩放:user-scalable=no 是明确的 WCAG 违规。
  • 在真机上连续拖动验证:模拟器无法反映真实的字体渲染与 DPR 差异。
/* 最小可用版:三行搞定自适应排版 */

html {
  font-size: 100%;
  scrollbar-gutter: stable;
}

body {
  font-size: clamp(1rem, 0.94rem + 0.3vw, 1.125rem);
  line-height: 1.65;
}

.prose {
  max-width: 68ch;
  margin-inline: auto;
}

/* 进阶版:完整的字号与间距令牌 */
:root {
  --text-body: clamp(1rem, 0.94rem + 0.3vw, 1.125rem);
  --text-h2:   clamp(1.6rem, 1.05rem + 2.7vw, 2.5rem);
  --text-h1:   clamp(2rem, 1.1rem + 4.5vw, 3.5rem);
  --gap-grid:   clamp(1rem, 2vw, 2rem);
  --gap-block: clamp(3rem, 1.5rem + 6vw, 6rem);
}

自适应排版听起来是一个纯技术问题,但它的本质其实是尊重:尊重用户的屏幕尺寸,尊重用户的缩放设置,尊重阅读这件事本身的节奏。一个 320px 的手机和一个 2560px 的显示器,不应该被迫阅读同样大小的字;一个视力不佳的用户,也不应该因为设计者的“像素洁癖”而看不清内容。

当字体与间距真正做到自适应时,用户不会注意到任何“技术”——他们只会觉得这个网站“读起来很舒服”。而这种舒服,恰恰是响应式设计最应该追求的终点。如果你在自己的项目里实践这套方法,建议从最小的三行代码开始,再逐步引入令牌体系与容器查询——比起一次性重写整个样式表,渐进式的改进更容易验证,也更容易坚持下来。