全景:现代 Web UI 的版图
钻进标签和属性之前先回答三个问题:它解决什么问题、今天的版图长什么样、和邻近方案怎么选。后面每一章都是这张地图的放大。
这一页的立身之本是一句话:现代 CSS 已今非昔比——Grid、容器查询、原生嵌套、:has()、层叠层全部原生可用,大量过去必须靠预处理器和 JS 的场景,今天几行原生 CSS 就够。
平台原生能力已经补齐了什么
- 布局:Grid + Flexbox 覆盖几乎所有布局,
repeat(auto-fit, minmax())一行实现响应式网格(08 章); - 组件化响应:容器查询让组件按「放它的容器」而非视口自适应,真正可复用(11 章);
- 样式架构:
@layer把优先级从「谁写得刁钻谁赢」变成显式分层。
怎么选:原生 CSS vs Tailwind vs CSS-in-JS
- 原生 CSS + 设计令牌:内容型站点、长生命周期项目——零依赖,平台能力只增不减;
- Tailwind:组件化框架里快速迭代产品 UI,v4 底层就是现代原生 CSS;
- CSS-in-JS:样式强依赖运行时状态时才值得,代价是运行时开销与 SSR 复杂度。
/* 这几行放在几年前全都不可能 —— 现在无需任何工具 */
.card {
container-type: inline-size;
& h2 { font-size: 1.25rem; } /* 原生嵌套 */
&:has(img) { /* 父选择器 */
display: grid;
grid-template-columns: 120px 1fr;
}
}
/* 按容器宽度而非视口响应 —— 组件放哪都自适应 */
@container (min-width: 400px) {
.card h2 { font-size: 1.5rem; }
}上手:跑通第一个页面
在讲任何标签和属性之前,先让你在自己的浏览器里看到东西。这一章只做四件事:写出并打开第一个页面、看懂那几行骨架各自的职责、学会打开 DevTools 看真实的 DOM 与样式、以及记住一条会省下无数时间的事实——CSS 写错不会报错,它只会静默失效。后面所有章节的代码片段都默认你已经能把它跑起来。
Web 是极少数什么都不用装就能开始的技术栈:浏览器就是运行时。建一个 index.html,粘进右侧代码,双击它——改完按 F5 刷新,没有编译也没有构建。
这段骨架里没有一行是装饰
<!DOCTYPE html>——唯一作用是让浏览器进入标准模式;省掉会退回怪异模式,最容易撞上的是无单位长度被当成 px;lang="zh-CN"——屏幕阅读器据此选发音引擎,自动断词与中日韩字形选择也依赖它;<meta charset="utf-8">——必须放<head>最前面:浏览器边下载边解析,得在读到正文前就知道编码,否则中文已经按错编码解析成乱码;viewport那行不写,移动浏览器会假装屏幕宽 980px 再缩小:字小到看不清,而且媒体查询全部失灵(11 章)。
什么时候需要一个本地服务器
file:// 在写纯 HTML/CSS 阶段够用,但一旦用上 ES 模块或 fetch() 读本地文件就会撞墙——这两件事要过跨源检查,而 file:// 的源不透明,一律被拒。解法是让页面跑在 http:// 上:VS Code 的 Live Server 扩展,或在目录里 npx serve。
<!-- index.html —— 存成这个名字,双击就能看 -->
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>我的第一个页面</title>
<style>
body { font-family: system-ui, sans-serif; margin: 2rem; }
h1 { color: #2563eb; }
</style>
</head>
<body>
<h1>Hello, Web!</h1>
<p>改这行字,保存,回到浏览器按 F5。</p>
</body>
</html>
<!-- ---- 想跑在 http:// 上时,在该目录执行其一 ---- -->
<!-- npx serve (需要 Node.js) -->
<!-- 或 VS Code 右键 → Open with Live Server -->viewport 那行最常见的错误是加 user-scalable=no——这剥夺了视力不佳者放大页面的能力,是无障碍硬伤。.html 后输入 ! 再按 Tab(Emmet),四行一个不少地展开出来。写 CSS 时你其实在做盲操作:改个数字、刷新、看结果。DevTools 把这个循环压缩到零。
① Elements:看真实的 DOM 和真正生效的样式
- 左边那棵树不是你写的 HTML 源码,是浏览器解析、修正、以及 JS 改动之后的实时状态;
- 点中元素,右边 Styles 自上而下列出命中它的规则,越靠上优先级越高;被划掉的声明就是没生效的——排查「我明明写了却没用」的第一现场;
- 切到 Computed 看所有属性的最终计算值。
② 直接改样式 ③ Console 看报错
- 点中声明的值就能编辑,数值用方向键微调;Styles 顶部的
:hov能强制元素保持:hover状态——调这类样式时这是唯一可用的办法。所有改动刷新即丢失; - 页面「点了没反应」,第一件事是看 Console 有没有红字:JS 一旦抛出未捕获错误,后面的代码就不再执行,而页面本身不给任何提示。
/* 假设你写了这两条规则,然后在 Elements 里选中那个按钮 */
.btn { color: white; background: #2563eb; }
button.btn { background: #dc2626; }
/* Styles 面板会这样呈现(上面的优先级更高):
button.btn style.css:12
background: #dc2626; ← 生效
.btn style.css:8
color: white; ← 生效
background: #2563eb; ← 被划掉 = 被上面那条覆盖了
*/
// ---- Console 里的几句常用自检($0 = Elements 中选中的元素)----
// $0 看是不是选对了元素
// getComputedStyle($0).display 最终的 display 到底是什么
// getComputedStyle($0).fontSize 1rem 折算成多少 px
// $0.getBoundingClientRect() 实际渲染出的宽高与位置
// document.querySelectorAll('.btn').length 选择器到底匹配到几个这是初学者最大的挫败来源:CSS 没有报错机制。属性名拼错、值写错、单位漏了,浏览器不弹提示、Console 里也没红字——它只是安静地把那一行当作不存在。你只会看到「我改了,但没反应」。
丢弃粒度:规范定下的错误恢复规则
- 一条声明写错 → 只丢这一条(属性名拼错、值非法、数值漏单位、少写分号导致两条粘成一条);
- 选择器写错 → 整条规则被丢弃,而且选择器列表里只要有一个无效就全体连坐——这正是
:is()/:where()宽容列表存在的理由之一(06 章); - 花括号不配对时后面的规则会被吞进来。所以「从某一行开始下面全不生效」,先去上面找漏掉的
}。
「改了没反应」的四种症状
| 在 Styles 里看到的 | 原因与对策 |
|---|---|
| ① 声明根本不在 | 本身非法被丢弃,或选择器没匹配上。Console 敲 document.querySelectorAll('选择器').length:0 是选择器问题,非 0 就核对属性名和单位 |
| ② 在,但被划掉 | 被优先级更高或同名后写的规则覆盖(层叠规则见 05 章) |
| ③ 在、没划掉,却没效果 | 元素没尺寸、被遮住,或该属性对它的 display 不适用——典型是给行内元素设 width 和上下 margin(07 章) |
| ④ 整个文件都没生效 | CSS 根本没加载:href 写错、大小写不符、rel 漏了,去 Network 找 404 |
/* ===== 故意写错的示例:猜猜哪些生效? ===== */
/* ① 属性名拼错 —— 只丢这一条,另外两条正常 */
.card {
colr: #333; /* ✗ 拼错,被丢弃,Styles 里根本不出现 */
background: #f5f5f5; /* ✓ 照常生效 */
}
/* ② 漏单位 —— 除了 0,长度值必须带单位 */
.card {
margin-top: 16; /* ✗ 非法,丢弃(要写 16px) */
margin-bottom: 0; /* ✓ 0 是唯一可以不带单位的 */
}
/* ③ 选择器无效 —— 整条规则作废,h1 和 h3 被连累 */
h1, h2::totally-not-real, h3 {
color: crimson; /* ✗ 三个选择器一个都不生效 */
}
:is(h1, h2::totally-not-real, h3) {
color: crimson; /* ✓ 换成宽容列表,h1 和 h3 正常 */
}
/* ④ 语法完全合法,Styles 里也不划掉,页面上就是没效果 ——
属性对该 display 类型不适用,最难查的一类 */
span.badge {
width: 200px; /* 行内元素上无效 */
margin-top: 20px; /* 行内元素的上下 margin 也无效 */
/* 修法:加 display: inline-block 或 block */
}.btn:hover 写成 .btn :hover(中间多个空格),后者变成「.btn 的后代里被 hover 的元素」——合法、不报错、永远不生效。display 的问题。三种情况指向三个完全不同的方向,先分诊再动手。装个 stylelint 能把「静默失效」变回「有报错」。HTML 结构与文档
01 章已经让一个页面跑起来了。这一章回答的是它背后的规则:什么能放进 head、什么必须放进 body、浏览器遇到写错的标记会怎么替你收拾残局,以及脚本和资源该以什么姿势加载。搞清楚这些,你才能看懂 DevTools 里的 DOM 为什么和你写的源码不一样。
骨架长什么样,01 章已经逐行拆过了。这里讲的是它背后的规则:一份 HTML 文档被切成两个互不相通的世界——<head> 装元信息(描述这份文档的数据,不渲染),<body> 装内容(用户看得见的一切)。放错地方不会报错,浏览器会默默把它挪走或忽略,然后你对着屏幕查半小时。
head 里能放什么:清单是封闭的
<title>(必需且只能有一个)、<meta>、<link>、<style>、<script>、<base>——就这些;- 共同点是它们都不产生可见内容。
<style>和<script>在 body 里也合法,但其余几个只该待在 head; - 一旦浏览器在 head 里读到一个不属于这份清单的标签(比如你误写了一个
<div>),它就认为「head 到此结束」,自动闭合 head 并把后面的一切当成 body 的内容。
浏览器的容错:源码 ≠ DOM
HTML 解析器没有语法错误这一说,它的规范里写死了「遇到不合法输入该怎么办」。于是:
<html>、<head>、<body>三个标签全部可以不写,解析器会替你补出来——一个只有一行<p>hi</p>的文件,DOM 里照样是完整的 html/head/body 三层;- 忘记闭合的
<li>、<p>会在遇到下一个同级标签时被自动闭合; - 想看真实结果:打开 DevTools 的 Elements 面板看到的是 DOM 树,而 View Source(Ctrl+U)看到的才是你写的源码。两者不一致是常态,这也是排查「我明明写了却不生效」的第一现场。
foster parenting:被表格踢出来的元素
最容易撞见的容错行为发生在表格里。<table> 内部只允许 caption/colgroup/thead/tbody/tfoot/tr,你若在 <table> 和 <tr> 之间塞一个 <div>,解析器不会报错,而是把它整个搬到 table 的前面去——这个行为规范里就叫 foster parenting(寄养)。结果就是内容跑到了表格上方、CSS 选择器再也匹配不中,而源码看起来完全正常。
把这两条规则跑给浏览器看
(Chrome headless 加 --dump-dom,看解析后的真实 DOM)
你写的: <p>一<div>二</div>三</p>
解析成的: <p>一</p><div>二</div>三<p></p>
↑ p 被强行闭合 ↑ 末尾那个 </p> 又造出一个空 p
你写的: <table><tr><td>格</td></tr>游离文本</table>
解析成的: 游离文本<table><tbody><tr><td>格</td></tr></tbody></table>
↑ 文本被挪到了表格前面 ↑ 顺手补上你没写的 <tbody>- 两个例子都不是「忽略非法标记」,而是主动改写你的树:第一个多出了一个你没写的空
<p>(</p>在没有开放 p 时会新建一个),第二个把文本寄养到了表格前面。 - 所以那句「源码和 DOM 是两样东西」有了具体形态:CSS 选择器、
querySelector、框架 diff 全部作用在解析后的 DOM 上。「样式明明写对了却不生效」的一类根因就是结构在解析阶段已经被改了——Elements 面板里看到的才是真相。
<!-- 你写的源码:三个标签一个都没有 -->
<p>只有这一行</p>
<!-- 浏览器解析出的 DOM(DevTools Elements 里看到的):-->
<!-- html > head(空)+ body > p -->
<!-- ❌ table 里位置写错:div 会被「寄养」到 table 之前 -->
<table>
<div>我以为我在表格里</div> <!-- foster parenting -->
<tr><td>A</td></tr>
</table>
<!-- 实际 DOM:div 跑到了 table 外面、上面 -->
<!-- <div>我以为我在表格里</div> -->
<!-- <table><tbody><tr><td>A</td>… -->
<!-- 注意:tbody 也是解析器自动补出来的 -->
<!-- ✅ 正确:内容放进单元格 -->
<table>
<tbody>
<tr><td><div>在表格里</div></td></tr>
</tbody>
</table></li> 也能跑」就真的不写:容错规则各处细节不同,一旦你的嵌套稍微复杂一点,补出来的 DOM 就可能不是你想要的。另一个高频坑是把 <link rel="stylesheet"> 或 <meta> 写进了 body——它们不会报错,可能碰巧生效,但行为不受保证,而且 <meta charset> 写晚了会直接导致中文乱码(规范要求它出现在文档前 1024 字节内)。HTML 解析是自上而下的单线程过程,而 <script> 有权在解析中途插进来改 DOM。浏览器无法预知它会改什么,于是默认策略只能是最保守的:停下解析,等脚本下载并执行完,再继续。defer 和 async 就是让你告诉浏览器「不必这么保守」的两种方式。
四种写法的对照
| 写法 | 阻塞解析 | 执行时机 | 多脚本顺序 | 用在哪 |
|---|---|---|---|---|
| 默认(无属性) | 是 | 下载完立即执行 | 按文档顺序 | 几乎不用;除非后续 HTML 依赖它 |
defer | 否 | HTML 解析完之后、DOMContentLoaded 之前 | 按文档顺序 | 默认选它:业务脚本 |
async | 否 | 下载完立即执行,可能打断解析 | 不保证(谁先下完谁先跑) | 与 DOM、与其它脚本都无关的独立脚本(统计、埋点) |
type="module" | 否 | 同 defer(默认就是 defer 行为) | 按文档顺序 | 用 import/export 的现代代码 |
module 还额外给了你什么
- 独立作用域:顶层
const/let不再污染全局,不用再手写 IIFE 包一层; - 自动严格模式,且
this在顶层是undefined而非window; - 可以用
import,且同一个模块被多处引入只执行一次; - 想让模块不等到解析完就执行,可以额外加
async——<script type="module" async>是合法组合。
那「script 放 body 末尾」还有意义吗
这是 defer 普及之前的经典做法,目的一样:别挡住渲染。但 defer 更优——放在 <head> 里的 defer 脚本下载可以和 HTML 解析并行,比等解析到 body 末尾才开始下载早得多。今天的推荐姿势是:head 里写 <script src defer>。
<!-- 默认:阻塞解析,等下载并执行完才继续 -->
<script src="app.js"></script>
<!-- defer:并行下载,解析完后按顺序执行 ✅推荐 -->
<script src="app.js" defer></script>
<!-- async:下载完立即执行,顺序不保证(统计脚本)-->
<script src="analytics.js" async></script>
<!-- module:ES Module,自动 defer,独立作用域 -->
<script type="module" src="main.js"></script>①
defer 和 async 对内联脚本完全无效,只对带 src 的外部脚本生效。写 <script defer>console.log(1)</script> 里的 defer 会被直接忽略,脚本仍然立即阻塞执行——这条最容易骗过人,因为它不报任何错。(type="module" 是例外,内联 module 也是 defer 行为。)②
async 脚本里访问 DOM 会随机拿到 null:它下载完就跑,此刻 HTML 可能才解析到一半,document.querySelector('#footer') 返回 null,而且本地开发时因为下载快,往往复现不出来,一上线就炸。给统计脚本加 async 没问题,给业务脚本加就是埋雷。head 里的标签不渲染任何东西,却决定了三件事:搜索引擎怎么收录你、分享到社交平台长什么样、首屏能多快画出来。它们互相无关,按这三组分别配齐即可。
① SEO 组
<title>:搜索结果的大标题,也是标签页文字,每页都该不同;<meta name="description">:搜索结果下方的摘要,150 字内。它不参与排名,但直接影响点击率;<link rel="canonical">:告诉搜索引擎「这一堆 URL(带?utm_source=之类参数的)其实是同一个页面」,把权重合并到一处,避免被判重复内容。
② 分享卡片组:Open Graph
微信、Slack、Twitter/X、Discord 抓取链接生成预览卡时,读的是 Open Graph 标签而不是 <title>。注意它们用的是 property= 而非 name=(Twitter 那套用 name=,是个不一致的历史遗留)。至少配齐 og:title / og:description / og:image / og:url 四个,og:image 必须是绝对 URL,写相对路径抓取端解析不了。
③ 资源提示:三个 rel 各管一段
| rel | 做什么 | 什么时候用 |
|---|---|---|
preconnect | 提前完成 DNS + TCP + TLS 握手,但不下载 | 确定会用到的第三方域(字体、CDN),且暂时不知道具体文件名 |
dns-prefetch | 只做 DNS 解析,成本更低 | 作为 preconnect 的兜底,或域名较多时 |
preload | 立刻以高优先级真正下载某个具体文件 | 首屏关键、但被 CSS/JS 深埋而发现得晚的资源(字体、LCP 大图) |
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<!-- SEO -->
<title>页面标题 | 网站名</title>
<meta name="description" content="150 字内摘要">
<link rel="canonical" href="https://example.com/page">
<!-- 社交分享卡片 -->
<meta property="og:title" content="标题">
<meta property="og:image" content="https://example.com/og.png">
<meta name="twitter:card" content="summary_large_image">
<!-- 图标 + 性能预加载 -->
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<link rel="preload" href="font.woff2" as="font" crossorigin>
<link rel="preconnect" href="https://fonts.googleapis.com">
</head>preload 必须写对 as。as 决定了请求的优先级、Accept 头和缓存归属;写错(比如给字体写成 as="style")或干脆不写,浏览器就无法把预加载的结果和后续真实请求对上,于是完整地再下载一遍——本想加速,实际是双倍流量。另外 as="font" 必须同时加 crossorigin(哪怕字体是同源的),否则同样匹配不上、重复下载。②
preconnect 不能滥用。每个连接都占用浏览器有限的并发额度,对一堆「也许会用到」的域名全部 preconnect,等于让真正关键的请求去排队;同时未被使用的连接过一小会儿就会被浏览器关掉,纯属白花开销。控制在 2–4 个真正关键的域内。③ preload 了却没在页面里用上,控制台会给你一条明确的警告——上线前扫一眼 Console 就能发现。
<img src>、<link rel=stylesheet> 预扫描器一眼就看到了,给它们加 preload 毫无意义。真正值得 preload 的是那些藏在 CSS 里(@font-face 的 woff2、background-image 的首屏大图)或要等 JS 跑完才请求的资源——它们要等到 CSS/JS 下载解析完才被发现,preload 能把这段等待省掉。<div>(块级,独占一行)和 <span>(行内,随文字流)是 HTML 里仅有的两个明确宣告「我没有任何语义」的元素。这不是缺陷,而是它们的功能——当一个包装层的存在理由纯粹是给 CSS 抓的,用有语义的标签反而是错的。
什么时候 div 就是正确答案
- 纯布局包装:为了做 flex/grid 容器、为了限制宽度、为了加圆角背景而多套的一层——这层在内容大纲里根本不该存在,用 div;
- 组件的最外层壳:一张卡片的外框
<div class="card">是合理的,除非它内部真的是一篇独立内容(那才轮到<article>); - 行内的样式钩子:给一句话里的价格、单位、高亮片段套色,用
<span>——但先想想有没有更贴切的行内语义标签(<strong>/<em>/<time>/<code>,见 03 章)。
「优先语义标签」的正确读法
这条建议真正的意思是:当一段内容确实符合某个语义标签的定义时,别用 div 代替它。导航就用 <nav>、页脚就用 <footer>——写成 <div class="nav"> 是白白丢掉了辅助技术的地标导航能力。但它不等于「div 越少越好」:把不符合定义的东西硬塞进语义标签,是把噪音注入了可访问性树,比用 div 更糟。
块级 / 行内:CSS 时代的一点补充
「块级」「行内」说的是元素默认的 display 值,而 CSS 可以随时改写它(display: flex 的 div、display: block 的 span)。但改 display 不改变语义,也不改变内容模型:把 <span> 设成 display: block 之后往里塞 <div> 仍然是不合法的 HTML(<span> 只能装短语内容)。同理,<p> 里嵌 <div> 会被解析器自动闭合 <p>——这是上一张卡说的容错行为,改 CSS 救不了。
<!-- div:块级容器,用于布局分块 -->
<div class="card">
<div class="card-body">...</div>
</div>
<!-- span:行内容器,用作样式钩子 -->
<p>价格:<span class="price">¥99</span></p>
<!-- ❌ 不推荐:用 div 模拟语义 -->
<div class="nav">...</div>
<!-- ✅ 推荐:用语义标签 -->
<nav>...</nav><section>。 规范对 <section> 的定义是「内容的一个主题性分组,通常带有标题」——一个没有标题的 <section> 不但没帮上忙,还有实际代价:在可访问性映射里,<section> 只有在拿到可访问名称(有标题并用 aria-labelledby 关联,或写了 aria-label)时才会成为 region 地标;否则它对屏幕阅读器什么都不是,你只是写了个更长的 div。没有标题,就用 div。 同理,别把 <main>、<article> 当成「高级 div」乱用。HTML 语义化
语义标签不改变一个像素的外观,所以它常被当成玄学。但它换来的是三件具体的东西:屏幕阅读器的地标跳转、阅读模式与搜索引擎的内容提取、以及交互元素自带的键盘行为。这一章把「为什么要用」和「用错会怎样」一起讲清楚。
先回答那个所有人都想问的问题:语义化到底换来了什么?不是玄学,是三件能验证的事——而这张卡讲的布局标签,兑现的是其中最值钱的一件:地标(landmark)导航。
语义换来的三件具体的事
- ① 地标导航:屏幕阅读器用户可以用一个快捷键列出页面所有地标,直接跳到「主内容」「导航」「搜索」,不必从头 Tab 到尾。写成
<div class="nav">,这个列表里就什么都没有; - ② 内容提取:浏览器阅读模式、稍后读工具、搜索引擎的正文抽取,都靠
<main>/<article>判断「哪块是正文、哪块是边栏广告」; - ③ 自带的交互行为:
<button>天生可 Tab 聚焦、Enter 和 空格都能触发、被禁用时自动移出 Tab 序;<a href>可聚焦、Enter 触发、支持右键新标签页打开。这些全是白送的,用 div 模拟就得自己一条条补回来(详见 04 章)。
标签到地标的对应
| 标签 | 地标角色 | 用来装 |
|---|---|---|
<header> | banner(仅当它不在 article/section 内) | 站点级页头:logo、站名 |
<nav> | navigation | 成组的导航链接(不是页面里每个链接都要包) |
<main> | main | 本页独有的主内容,一页只能有一个 |
<aside> | complementary | 与主内容相关但可独立存在:相关文章、广告位 |
<footer> | contentinfo(同样仅当它不在 article/section 内) | 版权、备案号、站点地图 |
<section> | region(仅当有可访问名称时) | 带标题的主题分组 |
<article> | article | 可独立分发的内容单元 |
article 还是 section
判据是「这块内容单独拿出去还成立吗」:能做成 RSS 的一条、能单独分享出去的(一篇博文、一条评论、一张商品卡)用 <article>;只是当前页面内的一个主题区块(「产品特性」「常见问题」)用 <section>。article 可以嵌套——评论列表里的每条评论都是文章 article 内的子 article,这是规范鼓励的用法。
<body>
<header>
<nav><a href="/">首页</a></nav>
</header>
<main> <!-- 每页只有一个 -->
<article> <!-- 独立可复用内容 -->
<h1>文章标题</h1>
<section> <!-- 带标题的章节 -->
<h2>章节</h2><p>...</p>
</section>
</article>
<aside>相关文章</aside> <!-- 侧栏 -->
</main>
<footer>© 2026 公司名</footer>
</body>①
<header>/<footer> 放进 <article> 或 <section> 后就是「局部」的了——它们不再是 banner/contentinfo 地标,而只是这篇文章自己的头和尾(放作者、发布时间、文末标签)。所以一页里可以有很多个 <header>,这完全合法,别以为写重复了。② 一页多个
<nav> 必须用 aria-label 区分。主导航、面包屑、页脚导航都是 nav,屏幕阅读器读出来会是三个一模一样的「navigation」,用户根本不知道该进哪个。写 <nav aria-label="主导航">、<nav aria-label="面包屑"> 即可。注意 label 文字里不要带「导航」以外的冗余词如「导航区域」,屏幕阅读器会把角色名一并读出,变成「面包屑导航 导航」。③
<main> 一页只能有一个(严格说:只能有一个不带 hidden 的)。SPA 里换路由时复用同一个 main,别每个视图各写一个。标题不是「大号加粗的字」,而是页面的目录结构。屏幕阅读器用户浏览长页面最常用的动作,就是按 H 键在标题间跳、或者调出「标题列表」当目录用——这份目录完全由 h1~h6 的数字层级生成。字号是 CSS 的事,层级是 HTML 的事,两者必须解耦。
不能跳级,以及一个必须澄清的误解
- 规则:层级只能逐级下降(h2 之后可以是 h3,不能直接是 h4),但可以一次跳回多级(h4 之后回到 h2 表示上一节结束了,这是合法的);
- 为什么:从 h2 直接跳到 h4,屏幕阅读器生成的大纲里就出现了一个「缺了父节点的子节点」,用户会以为自己漏听了一段内容;
- 必须澄清的误解:早年 HTML5 提过一个「章节自动生成大纲」的设想——在
<section>/<article>里写<h1>,浏览器会按嵌套深度自动把它降级成 h2、h3。这个算法从未被任何浏览器或辅助技术实现,并且已经从 HTML 规范中移除了。今天规范给的明确建议就是:自己写对数字。嵌套多深,都得手动算准 h2/h3/h4;<section>里的<h1>在所有工具眼里就是一级标题。
h1 的取舍
「每页一个 h1」是长期以来的建议,理由是给页面一个唯一的主题。现代规范并不强制,但照做没有坏处,且对 SEO 与大纲清晰度有好处——保持一个 h1(通常等于页面标题),其余全部从 h2 开始。
三种列表,各有各的场合
<ul>:顺序无关紧要。导航菜单、标签列表、卡片网格都该是 ul——屏幕阅读器会播报「列表,共 5 项」,这个数量信息很有用;<ol>:顺序有意义(步骤、排名)。start指定起始号、reversed倒序、单项用value跳号;<dl>:名值对。术语表、商品参数表、元数据(作者/日期/分类)都适合。结构是一个<dt>可以配多个<dd>,反之亦然;<blockquote>块引用(cite属性放来源 URL,它不会显示出来,要给读者看的出处用<figure>+<figcaption>包一层);<hr>现在表示主题的转换而非一条横线。
<h1>页面主标题</h1> <!-- 每页一个,按层级用 -->
<h2>一级章节</h2>
<ul><li>苹果</li><li>香蕉</li></ul>
<ol start="3" reversed> <!-- 从 3 开始,倒序 -->
<li value="10">跳到 10</li>
</ol>
<dl> <!-- 术语 / 定义 对 -->
<dt>HTML</dt><dd>超文本标记语言</dd>
</dl>
<blockquote cite="https://example.com">
<p>引用内容</p>
</blockquote><h4>,需要大字就写 <h1>。正确做法是按层级选标签,再用 CSS 调字号。② 用
<p><b>标题</b></p> 或 <div class="title"> 冒充标题:视觉上一模一样,但它完全不出现在标题列表里,屏幕阅读器用户导航时这一节等于不存在。③ 指望
<section> 自动降级——见上文,那个算法不存在。这个误解在中文教程里流传极广,很多文章至今还在教「section 里可以放心用 h1」。④
<ul> 里只能直接放 <li>:在 <ul> 和 <li> 之间套一层 <div>(比如为了做 flex 布局)是不合法的,会破坏列表语义。要包装就包在 <li> 内部。行内语义标签解决的是同一个问题的两半:让文字带上含义,以及让机器读懂本来只有人能看懂的信息。挑选它们的判据只有一条——「我想表达的是含义,还是仅仅想要这个字体样式」。
语义 vs 纯视觉:四对孪生标签
| 语义标签 | 含义 | 视觉标签 | 用在 |
|---|---|---|---|
<strong> | 内容重要/紧急 | <b> | 需要加粗但不重要:产品名、文章开头的导语 |
<em> | 语气强调(读出来会重音) | <i> | 惯例用斜体:外文词、术语首次出现、书名、图标字体 |
<ins> | 后来新增的内容 | <u> | 非文本注释:拼写错误标注(慎用,容易被误认成链接) |
<del> | 已删除的内容 | <s> | 不再准确但未删除:划掉的原价 |
屏幕阅读器默认并不会为 strong/em 改变语调,所以别以为选错了会立刻听出来。但语义标签让内容在样式被替换(阅读模式、RSS、纯文本导出)后依然保留重点,这才是它的价值。
让机器读懂:带属性的那几个
<time datetime="2026-07-21">上周二</time>——人看到「上周二」,机器读到标准日期。datetime必须是 ISO 8601 格式,可以只写日期、也可以带时间和时区(2026-07-21T09:00+08:00);<abbr title="HyperText Markup Language">HTML</abbr>——鼠标悬停显示全称。但title在触屏上无法触发、键盘用户也拿不到,重要的全称应该在正文里首次出现时就写出来;<code>/<kbd>/<samp>/<var>——代码 / 用户按的键 / 程序的输出 / 变量,四个各司其职,浏览器默认都渲染成等宽字体;<mark>标记「与当前上下文相关」的部分,最典型的是搜索结果里高亮命中的关键词,而不是当荧光笔用。
字符实体:写出保留字符
< 和 & 是 HTML 的保留字符,要显示它们本身必须写成 < 和 &。常用的还有 >、 (不换行空格)、©、—。绝大多数场景直接写 UTF-8 原字符即可(页面已声明 charset=UTF-8),实体主要用在这几个保留字符上。
<strong>重要</strong> <!-- 语义:重要性(非纯粗体)-->
<em>强调语气</em> <!-- 语义:强调(非纯斜体)-->
<code>map()</code> <!-- 行内代码 -->
<kbd>Ctrl</kbd> + <kbd>C</kbd> <!-- 键盘按键 -->
<mark>高亮</mark> <del>删除</del> <ins>新增</ins>
H<sub>2</sub>O E=mc<sup>2</sup>
<abbr title="HyperText Markup Language">HTML</abbr>
<time datetime="2026-06-14T09:00">今天上午9点</time>
<!-- 常用实体:-->
< > & © — 被当成排版工具滥用:连打几个 来缩进或拉开间距,会在不同字体/缩放下彻底失控,也让复制出来的文本带上诡异的不可见字符。间距一律用 CSS 的 margin/padding/letter-spacing。 的正当用途只有一个:阻止特定两个词之间断行(如「10 kg」)。② 用
<i> 挂图标字体后忘了处理无障碍:<i class="icon-x"></i> 对屏幕阅读器是一个空元素,或者更糟——某些图标字体的字符会被读成乱码。必须加 aria-hidden="true",并给它外层的按钮写 aria-label。③
<u> 几乎总是错的:在网页上下划线文本的唯一约定含义就是「这是链接」,用它做别的只会制造误点。<em>。「就算撤掉全部 CSS,读者也必须知道这里重要吗?」必须 → <strong>。剩下那些「我只是想让它粗一点/斜一点」的场合,正确答案往往既不是 b 也不是 strong,而是给一个 <span class="..."> 用 CSS 控制。表格是二维数据,而屏幕阅读器是线性朗读的。用户听到「1200」时必须知道这是「手机 · 1 月」的交叉值——把这层对应关系告诉浏览器,正是表格语义标签的全部工作。做对了,用户可以用表格模式在单元格间上下左右移动,每移动一格自动播报对应的行头和列头。
四件必做的事
| 要素 | 作用 | 不写会怎样 |
|---|---|---|
<caption> | 表格的标题,必须是 table 的第一个子元素 | 屏幕阅读器在表格列表里只能报「表格」,用户不知道这是什么表 |
<th> | 标记「这一格是表头」而非数据 | 整张表被当成一堆无名数据,无从建立对应关系 |
scope="col" / "row" | 指明这个表头管的是一整列还是一整行 | 简单表格浏览器还能猜对,一旦有行头+列头混排就会猜错 |
<thead>/<tbody>/<tfoot> | 分区。还带来实用好处:长表格打印时 thead 会在每页重复 | 失去分区语义与打印重复表头 |
行头也要用 th
这是最常漏的一条:左侧那一列「产品名」是行的表头,应该写 <th scope="row">手机</th> 而不是 <td>。补上之后,用户在数据格上左右移动时才能听到「手机,1 月,1200」这样完整的上下文。真正复杂的表(多层表头、合并单元格)可以用 headers 属性显式指向表头的 id,但更好的办法通常是把它拆成几张简单表。
<table>
<caption>2026 Q1 销售</caption> <!-- 标题,利于无障碍 -->
<thead>
<tr>
<th scope="col">产品</th> <!-- scope 指明方向 -->
<th scope="col">1月</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">手机</th> <!-- 行头也用 th -->
<td>1200</td>
</tr>
</tbody>
</table><table> 做页面布局。 这不只是「不优雅」的问题:屏幕阅读器一进入 table 就会切换到表格模式并开始播报「表格,3 行 2 列」,把导航栏读成数据网格;同时表格布局在窄屏上几乎无法响应式处理。布局是 Flexbox/Grid 的工作(见 08 章)。顺带一提,邮件 HTML 是唯一的例外——各家邮件客户端对现代 CSS 支持极差,至今仍普遍用表格布局。那是被迫的历史遗留,别把它带回网页。
另一个坑:
<caption> 在内容模型上必须是 <table> 的第一个子元素。写在别处属于不合法标记、校验器会报错,虽然浏览器多半仍会照常把它渲染在表格顶部,但别指望各家一致。display: block 之类的手段强行改造成卡片流——那会连带破坏表格的可访问性语义。标准做法是给表格套一层 overflow-x: auto 的容器让它横向滚动,并给这个容器加 tabindex="0" 和 role="region"+aria-label,好让键盘用户也能滚动它。(本页所有表格就是这么处理的。)<details> + <summary> 是浏览器内置的折叠面板:点击标题展开收起、键盘 Tab 可达、Enter/空格可切换、状态正确暴露给屏幕阅读器——这一整套行为一行 JS 都不用写。自己用 div + click 事件做一个,要补齐同样的键盘和 ARIA 行为得写上几十行,还大概率漏掉几条。
你白得的原生行为
<summary>必须是第一个子元素,它就是那个可点击的标题;不写 summary 浏览器会给一个默认的「详细信息」;- 加
open属性默认展开。这个属性会随用户操作实时同步,所以 CSS 可以用details[open]选中展开态,JS 可以直接读el.open; - 展开/收起会触发
toggle事件,需要联动时监听它,别去监听 click; - 折叠内容可以被浏览器的页内查找(Ctrl+F)找到并自动展开——这背后是
hidden="until-found"机制,现代浏览器已普遍支持(各家在「找到后是否滚动到位」上仍有细节差异)。这是它相对于「用 CSSdisplay:none藏起来」的决定性优势:后者的内容对查找、对搜索引擎都等于不存在。
name:零 JS 的互斥手风琴
给一组 <details> 写相同的 name,它们就成为一个互斥组——展开其中一个,其余自动收起,这正是手风琴(accordion)的行为,同样不需要 JS。这是较新加入规范的能力,现代浏览器已普遍支持;在不支持的旧浏览器上会优雅降级成「各自独立、可同时展开」,不会坏掉,所以可以放心用。
它不能替代什么
- 不是下拉菜单:导航菜单需要方向键导航、Esc 关闭、失焦自动关闭,details 一样都没有;
- 不是模态框:那是
<dialog>的活(自带焦点陷阱与 Esc 关闭,见 04 章); - 不适合藏首屏关键内容:折叠虽然可被查找,但用户默认看不见。
<!-- 基本用法:summary 必须是第一个子元素 -->
<details>
<summary>如何退货?</summary>
<p>7 天内可无理由退货……</p>
</details>
<!-- open:默认展开;该属性随用户操作实时同步 -->
<details open>
<summary>已展开</summary>
<p>内容</p>
</details>
<!-- 相同 name = 互斥手风琴,展开一个自动收起其它 -->
<details name="faq"><summary>问题一</summary><p>…</p></details>
<details name="faq"><summary>问题二</summary><p>…</p></details>
<details name="faq"><summary>问题三</summary><p>…</p></details>
/* CSS:去掉默认三角,用 [open] 做状态切换 */
summary { list-style: none; cursor: pointer; }
summary::-webkit-details-marker { display: none; }
summary::after { content: "▸"; }
details[open] summary::after { content: "▾"; }
// JS:需要联动时监听 toggle,不要监听 click
el.addEventListener("toggle", () => console.log(el.open));<summary> 里不要再放交互元素(按钮、链接、输入框)。summary 本身就是可点击可聚焦的控件,往里嵌另一个控件会造成嵌套交互,键盘操作和屏幕阅读器播报都会出问题——想在标题栏右侧放个按钮,把它放到 summary 外面用 CSS 定位过去。② 别给
<details> 或 <summary> 手写 role/aria-expanded。浏览器已经自动维护展开状态了,手动再加一份只会和真实状态打架,出现「明明展开了却播报为已折叠」。③ 用 CSS 把内容藏起来 ≠ 折叠:如果你给 details 的内容加了
display: none 或 visibility: hidden 来做样式,就把 Ctrl+F 可查找这个最大的好处亲手扔掉了。summary { list-style: none; }(Chrome/Safari 另需 summary::-webkit-details-marker { display: none; }),然后用 details[open] summary::after 之类自己画箭头,靠 [open] 切换方向。展开动画一直是这个元素的老大难(内容高度未知,height: auto 无法过渡),如果只是想要个简单效果,过渡 opacity 或用固定高度更稳妥,别为了动画把整个组件改回 JS 实现。HTML 表单 · 媒体 · 无障碍
表单是用户把数据交给你的唯一入口,媒体决定页面有多重、多快,而无障碍决定这一切能不能被所有人用上。这一章覆盖输入控件与原生验证、响应式图片与嵌入内容,以及两张专门讲可访问性的卡——键盘可达和 ARIA。
一个表单要真正工作,有两条不写就静默失效的连接线:label 必须连到控件(否则点文字不聚焦、屏幕阅读器读不出这个框是干嘛的),控件必须有 name(否则它的值根本不会被提交)。这两条都不报错,都得靠你自己知道。
label 关联控件的两种方式
| 写法 | 形式 | 适合 |
|---|---|---|
| 显式关联 | <label for="x"> + <input id="x">,for 对应的是 id 不是 name | 主力写法。label 和控件可以分处不同容器,方便做左右布局 |
| 隐式关联 | <label>用户名 <input></label>,直接包起来,不需要 id | 复选框/单选框,省去为每一项编 id 的麻烦 |
关联之后你白得两样东西:点击文字也能聚焦/勾选控件(触屏上把点击热区从一个小方块扩大到一整行,这是实打实的体验提升),以及屏幕阅读器聚焦到控件时会念出 label。
name:新手最高频的「表单没数据」
提交表单时,浏览器把控件按 name 打包成 name=value 交给服务端。所以:
- 没写
name的控件,值完全不会出现在提交数据里——服务端收到的字段直接缺失,前端却看不出任何异常; id和name是两个不同用途的属性:id给 label 的for和 CSS/JS 用,name给提交用。习惯上写成一样的值,但缺一不可;- 被
disabled的控件同样不会被提交(想让它只读又要提交,用readonly); - 单选组靠相同的
name实现互斥——name 写重了会导致两组单选互相干扰,写漏了则整组失去互斥。
选对 type:移动端体验差异巨大
email/tel/url/number会让手机弹出对应的软键盘(@ 键、数字键盘),并附带基础格式校验;- 但
type="number"别用在「不做算术的数字」上——手机号、验证码、银行卡号用它会带来上下箭头、滚轮误改值、前导零丢失等问题。这类字段应该用type="text"+inputmode="numeric"+pattern; date/time/color/range/file直接调起系统原生控件,成本低但样式几乎不可定制,产品对外观有硬要求时才考虑自己造。
<form action="/submit" method="post">
<label for="user">用户名</label>
<input type="text" id="user" name="user"
required minlength="3" autocomplete="username">
<!-- 现代类型:自动键盘 + 验证 -->
<input type="email" name="email">
<input type="number" min="0" max="100" step="1">
<input type="date"> <input type="color">
<input type="file" accept=".pdf" multiple>
</form>placeholder 代替 label——这是最常见也最有害的表单反模式。一开始输入,提示文字就消失了,用户没法核对自己填的是哪个框;placeholder 的默认颜色对比度普遍不达标;部分辅助技术不会朗读它。placeholder 只能是格式示例(「如:138xxxx0000」),永远不能承担字段名。② 不想显示 label 时也不能删掉它,而应该用 CSS 视觉隐藏(
.sr-only 那套 clip 技巧)保留给屏幕阅读器,或退而求其次给控件写 aria-label。③
<form> 里只有一个文本框时,回车会直接提交表单(这是规范行为,不是 bug)——搜索框想拦住它得在 submit 事件里 preventDefault()。④
<button> 在 form 内默认 type="submit",写一个「切换密码可见」的小按钮忘了加 type="button",一点就把表单提交了——这个坑几乎每个人都踩过一次。autocomplete 值得认真填,它不是「让浏览器记住密码」那么简单:写对 autocomplete="username" / "current-password" / "new-password" / "one-time-code" / "street-address" 这类标准值,浏览器和密码管理器才能正确地自动填充,手机验证码才能被系统自动识别填入。这是投入产出比极高的一处细节,尤其对登录、注册、结账表单。HTML 自带一套原生表单验证:写上 required、pattern、min、maxlength 这些属性,浏览器就会在提交时拦截、聚焦到第一个非法字段并弹出气泡提示——零 JS。真正的难点不在于怎么开启它,而在于怎么让红色错误样式在「该出现的时候」才出现。
:invalid 的陷阱与 :user-invalid 的解法
直觉写法是 input:invalid { border-color: red; }。结果是:页面一加载,所有还没填的必填框就全红了——因为空的 required 字段从第一刻起就是 :invalid。用户什么都没做错,却被劈头盖脸一片红。
| 选择器 | 什么时候匹配 | 用来 |
|---|---|---|
:valid / :invalid | 随时,只看当前值合不合法 | 实时反馈(如密码强度条),不适合直接画红框 |
:user-invalid | 用户交互过之后(编辑并离开,或尝试提交)才匹配 | 画错误样式的正确选择 |
:user-valid | 同上,但值合法 | 画绿色对勾 |
:required / :optional | 按属性静态匹配 | 给必填项加星号 |
:placeholder-shown | 当前显示着 placeholder(即为空) | 做浮动标签效果 |
:user-invalid 和 :user-valid 现代浏览器已普遍支持,是这一整类问题最干净的解法——过去要靠 JS 在 blur 时给元素加一个 .touched 类才能做到同样的事。
接管验证:novalidate 与 setCustomValidity
- 浏览器默认的错误气泡样式完全不可定制,文案还跟随浏览器语言。要自己画错误提示,就在
<form novalidate>上关掉原生气泡——注意novalidate只关掉「拦截提交和弹气泡」,约束本身依然有效,:invalid、checkValidity()、validity对象照常工作,你仍然可以复用整套验证逻辑,只是自己决定怎么展示; el.setCustomValidity("两次密码不一致")把元素标记为非法并设定提示文案;校验通过时必须调用setCustomValidity("")清空,否则这个元素会永远处于非法状态,表单再也提交不了——这是使用它时的头号坑;el.validity告诉你为什么非法(valueMissing、typeMismatch、patternMismatch、tooShort、rangeOverflow…),据此给出精准文案;form.checkValidity()只返回真假;form.reportValidity()会同时触发提示 UI。
分组与多行输入
<fieldset>+<legend>对单选/复选组是必需的,不是可选装饰:每个 radio 自己的 label 只是「标准」「加急」,只有 legend 才提供「配送方式」这个组名——没有它,屏幕阅读器用户听到一串选项却不知道在选什么;<select>用<optgroup label>分组;它的下拉列表样式长期无法定制,需要搜索、多选、自定义外观时才上第三方组件(代价是要自己补全部键盘行为);<textarea>的初始内容写在标签之间而不是value属性里,且开合标签之间的换行和空格会原样成为内容——写成一行<textarea></textarea>最保险。
<fieldset>
<legend>配送方式</legend> <!-- 给一组控件命名 -->
<label><input type="radio" name="ship">标准</label>
<label><input type="radio" name="ship">加急</label>
</fieldset>
<select name="city">
<optgroup label="华北">
<option value="bj">北京</option>
</optgroup>
</select>
<textarea rows="4" maxlength="200"></textarea>
<input pattern="[0-9]{6}" title="6 位数字">required + :invalid = 一进页面满屏红,见上文,改用 :user-invalid。②
pattern 的正则自带首尾锚定(相当于整个值都要匹配),所以写 pattern="[0-9]{6}" 就够了,多写 ^…$ 反而可能出错;另外 pattern 对空值不生效,「必须填且必须是 6 位数字」得 required 和 pattern 一起写。③ 写了
pattern 就该写 title:格式不符时浏览器的默认提示只会说「请匹配所要求的格式」,title 的内容会被附加进去,成为用户唯一能看到的格式说明。④
setCustomValidity 忘记清空导致表单永久无法提交,见上文。⑤ 验证失败时只把边框变红是不够的:色盲用户看不出来。必须同时给出文字错误信息,并用
aria-describedby 把它关联到控件上。required/pattern/minlength)+ 用 :user-invalid 画样式,这两步覆盖绝大多数表单且完全不用 JS。只有在需要跨字段校验(确认密码)、异步校验(用户名是否已被占用)、或产品坚持要统一的错误提示外观时,才加 novalidate 接管。任何情况下服务端都必须重新校验一遍——前端验证是体验,不是安全边界,绕过它只需要打开一次 DevTools。链接是 Web 的原子。选 <a> 还是 <button> 的判据只有一条:会导航到别处(换 URL)就用 <a href>,只是在当前页触发一个动作就用 <button>。这不是洁癖——两者的键盘行为不同(链接只响应 Enter,按钮 Enter 和空格都响应),右键菜单不同,屏幕阅读器的播报和快捷导航方式也不同。
href 能装的东西
- 绝对/相对/根相对路径:
/about从站点根开始,./x相对当前目录,../x上一级; - 页内锚点
#id:跳到该 id 的元素。href="#top"或href="#"回到页顶(但href="#"常被当成「假链接」滥用,见 pitfall); - 特殊协议:
mailto:(可带?subject=&body=)、tel:(移动端直接拨号)、sms:; download属性:让浏览器下载而非打开,属性值作为建议的文件名。它只对同源资源(或明确允许的跨源响应)生效,指向第三方 URL 时通常会被忽略、直接导航过去。
无障碍:链接文字必须自解释
- 屏幕阅读器用户可以调出「页面全部链接」的列表,此时链接脱离了上下文——满屏的「点击这里」「阅读更多」「详情」对他们毫无意义;
- 写成「阅读《响应式设计指南》全文」这样自带信息的文字。实在要保持视觉简洁,用
aria-label补一个完整名称; - 相邻的图片链接和文字链接指向同一处时,应合并成一个
<a>,否则用户要连按两次 Tab 才能过去,还会听到两遍同样的目的地。
<a href="https://example.com">外部链接</a>
<a href="#section2">页内锚点</a>
<!-- 特殊协议 -->
<a href="mailto:hi@x.com?subject=你好">发邮件</a>
<a href="tel:+8610012345">打电话</a>
<!-- 下载 + 新标签页(注意 rel)-->
<a href="/report.pdf" download="报告.pdf">下载</a>
<a href="..." target="_blank"
rel="noopener noreferrer">新标签页</a>target="_blank" 要配 rel="noopener":不加的话新页面能通过 window.opener 把原页面偷偷导航到钓鱼站(tabnabbing)。现代浏览器已默认对 _blank 施加 noopener,但显式写出来才能覆盖到老浏览器;不想泄露来源页地址再加 noreferrer。②
<a href="#" onclick="..."> 是反模式:它是个假链接,会在地址栏留下 #、会污染浏览历史、右键「在新标签页打开」得到一个空页面,屏幕阅读器还会把它播报成链接让用户以为要跳转。触发动作请用 <button>。③
<a> 不写 href 就不再是链接:它会失去可聚焦性、失去 link 角色,键盘用户完全无法触达。④ 锚点跳转会被 sticky 顶栏挡住——目标元素滚到视口顶部,正好藏在固定导航栏底下。解法是给目标加
scroll-margin-top: 4rem(值取导航栏高度),一行 CSS 搞定,别去写 JS 算偏移。target="_blank",至少给用户一个视觉提示(一个外链小图标)并在 aria-label 里注明「(在新窗口打开)」。站内链接则几乎不该新开。「响应式图片」其实是三个不同的问题,各有各的解法,混着用就会写出一堆无效代码。先分清它们要解决什么,再选工具。
三个问题,三种工具
| 问题 | 工具 | 怎么工作 |
|---|---|---|
| 分辨率切换 同一张图,不同屏幕该下多大的 | srcset + sizes | 你申报每个候选文件的真实像素宽(800w)和图片在版面上会显示多宽(sizes),浏览器结合设备像素比自己挑 |
| 艺术指导 窄屏要换成裁剪过的另一张构图 | <picture> + <source media> | 按媒体条件选不同的图——不是同一张图的不同尺寸,而是真的换了一张 |
| 格式回退 想用 AVIF/WebP 但要兜底 | <picture> + <source type> | 浏览器从上往下取第一个它认识的 type,所以最新格式写最前面,<img> 兜底放最后 |
判据:只是「同一张图要不同尺寸」→ srcset 就够了,别上 picture;「构图/内容本身要变」或「要换格式」→ 才用 <picture>。
sizes:最容易写错的一个
srcset 用 w 描述符时,浏览器在 CSS 下载完之前就要决定下载哪张图,它那时还不知道图片的最终显示宽度,所以必须靠你用 sizes 告诉它。sizes="(max-width: 600px) 100vw, 400px" 读作「视口窄于 600px 时图片占满宽,否则固定 400px」。sizes 写错不会报错,只会让浏览器一直挑错尺寸——写大了下载超大图浪费流量,写小了显示模糊。
简单场景可以用 sizes="auto" 配合 loading="lazy"(较新的能力,让浏览器自己用布局后的实际宽度),或者直接改用 2x/3x 密度描述符——图片显示尺寸固定时后者简单得多。
width/height:防 CLS 的关键,别省
图片下载完成前浏览器不知道它多高,于是先按 0 高度排版,图片一到就把下面的内容整体顶下去——用户正要点的按钮突然跑掉了。这就是核心性能指标里的 CLS(累积布局偏移)。
解法:在 <img> 上写死 width 和 height 属性(写图片的原始像素值,不带单位)。浏览器据此算出宽高比并提前预留出空间。关键点:这不会锁死尺寸——只要 CSS 里有 img { max-width: 100%; height: auto; },图片照样是响应式的,属性只是提供了宽高比。<picture> 的各个 <source> 也各自支持 width/height。
<img src="cat.jpg" alt="橙猫坐在窗台"
width="800" height="600" <!-- 防布局抖动 -->
loading="lazy">
<!-- srcset:浏览器按屏幕选分辨率 -->
<img src="p-400.jpg"
srcset="p-400.jpg 400w, p-800.jpg 800w"
sizes="(max-width: 600px) 100vw, 400px"
alt="响应式图">
<!-- picture:按格式/条件选源 -->
<picture>
<source srcset="p.avif" type="image/avif">
<source srcset="p.webp" type="image/webp">
<img src="p.jpg" alt="兜底">
</picture>alt 不是可选项,但写什么取决于图片的角色:承载信息的图要描述其内容(「橙猫坐在窗台」而不是「图片」「cat.jpg」);纯装饰的图必须写 alt=""(空字符串),这会让屏幕阅读器直接跳过它——而完全不写 alt 属性效果截然不同:部分屏幕阅读器会退而朗读文件名,用户听到一长串「p-800-final-v2-dot-jpg」。② 图片是链接的唯一内容时,
alt 要写链接的目的地而不是图片长什么样——此时 alt 就是这个链接的可访问名称。③
<picture> 里的 alt 写在 <img> 上,不是写在 <source> 上;<img> 也必须存在,它既是兜底也是真正被渲染的那个元素,CSS 也是选它。④ 只写
srcset 不写 sizes(用 w 描述符时)浏览器会默认按 100vw 处理,在多栏布局里几乎必然下载过大的图。loading="lazy"(懒加载会推迟它,直接拖慢 LCP),可以加 fetchpriority="high" 提升优先级。loading="lazy" 只该给首屏之外的图片。这是最常见的「优化优化出反效果」。这三类元素的共同点是都把「别人的东西」搬进你的页面——大体积的媒体、第三方的网页、需要另一套 API 才能画的图形。共同的关注点也一样:别拖慢首屏,别把安全边界拆掉。
音视频:默认就很重,要主动减负
preload="none"或"metadata":默认行为可能预下载相当多的数据。首屏有视频但用户未必会播时,用metadata(只取时长和尺寸)甚至none;poster给一张封面图,既避免黑框又让首屏可控;- 自动播放必须静音:浏览器普遍禁止带声音的自动播放,
autoplay必须配muted才会生效(通常还要加playsinline,否则 iOS 会全屏播放); <track kind="captions">字幕不是可选项:对听障用户是必需,对在安静环境或嘈杂环境看视频的所有人都有用。captions(含音效描述,给听障用户)和subtitles(只翻译对白)是两个不同的 kind。
iframe:三个属性划出安全和性能边界
| 属性 | 管什么 | 怎么用 |
|---|---|---|
sandbox | 默认关掉一切特权,再按需一项项开回来 | allow-scripts(跑脚本)、allow-forms(提交表单)、allow-popups、allow-same-origin(保留同源身份) |
allow | 权限策略:摄像头、麦克风、地理位置、全屏等设备能力 | allow="fullscreen; picture-in-picture";不写就是不给 |
loading="lazy" | 推迟加载到接近视口时 | 嵌入的视频/地图往往拉进来一整套第三方脚本,这个属性对性能的收益极大 |
另外 <iframe> 必须写 title——屏幕阅读器把每个 iframe 当成一个可导航的独立区域,没有 title 用户只会听到「框架」。
SVG vs Canvas:按「元素多不多、要不要交互」选
| SVG | Canvas | |
|---|---|---|
| 本质 | 矢量、每个图形都是 DOM 节点 | 一块像素画布,画完就没有对象了 |
| 缩放 | 任意放大都清晰 | 放大会糊(需按 DPR 重画) |
| 交互 / 样式 | 可用 CSS 选中、可绑事件、可被辅助技术读到 | 全部要自己算坐标、自己实现命中检测 |
| 元素数量 | 几千个节点后开始变卡 | 几万个图元仍然流畅 |
| 适合 | 图标、logo、图表、示意图 | 游戏、粒子效果、实时数据流、图像处理 |
一句话判据:要交互和可访问性 → SVG;要每秒重画几万个东西 → Canvas。绝大多数图表需求都该选 SVG。
<video controls width="640" poster="cover.jpg">
<source src="v.webm" type="video/webm">
<source src="v.mp4" type="video/mp4">
<track kind="subtitles" src="zh.vtt" srclang="zh" default>
</video>
<iframe src="https://youtube.com/embed/x"
title="教学视频" loading="lazy"
sandbox="allow-scripts"></iframe>
<!-- SVG:矢量,可内联,CSS 可控样式 -->
<svg viewBox="0 0 100 100" role="img" aria-label="圆">
<circle cx="50" cy="50" r="40" fill="#534AB7"/>
</svg>sandbox="allow-scripts allow-same-origin" 同时开启,对同源内容等于没有 sandbox——被嵌页面拿回了同源身份又能跑脚本,可以直接把父页面上自己的 sandbox 属性删掉再重载。嵌入不可信内容时这两个绝不能同时给。②
sandbox 属性存在但值为空字符串(sandbox="")= 最严格,脚本、表单、弹窗全禁。想放开就得逐项列出,别以为写了 sandbox 就是「开启保护、其余照常」。③ Canvas 里画的东西对屏幕阅读器完全不存在——它就是一块像素。用 Canvas 做的图表必须在旁边提供等价的文字或数据表格,否则这部分内容对辅助技术用户就是空白。
④
autoplay 不配 muted 会被浏览器静默拒绝,视频停在第一帧,控制台可能什么都不说,很容易查半天。<img src="x.svg">)才能用 CSS 控制它的 fill/stroke——这是图标跟随主题变色的标准做法。内联时给它加 role="img" + aria-label(有含义的图形)或 aria-hidden="true"(纯装饰,旁边已有文字)。注意 <img> 引用的 SVG 处于隔离环境,外部 CSS 和脚本都进不去。可访问性里最容易验证、也最容易被搞坏的一项是:把鼠标拔了,你的页面还能用吗?键盘用户、屏幕阅读器用户、以及任何暂时用不了鼠标的人,全靠 Tab / Shift+Tab / Enter / 空格 / Esc / 方向键操作页面。这张卡讲的就是怎么不破坏它。
Tab 顺序跟随 DOM 顺序,而 CSS 能让视觉顺序和它脱节
浏览器按元素在 DOM 里的先后决定 Tab 走位,完全不看它们被 CSS 摆到了屏幕上的哪里。于是这些写法会制造真实的 a11y bug:
flex-direction: row-reverse/column-reverse——视觉上「取消 / 确定」变成了「确定 / 取消」,Tab 却仍按 DOM 顺序走;order: -1把某个卡片提到最前——用户看到它在第一个,Tab 却要走到最后才能到达;- Grid 的
grid-area/grid-row显式定位造成的重排,以及position: absolute把元素挪到别处。
后果是用户看着焦点框在屏幕上乱蹦,完全无法预测下一次 Tab 会去哪。规则:需要重排顺序时,改 DOM 顺序,而不是用 CSS 改视觉顺序。CSS 的 order 只适合用在「顺序本身无所谓」的纯装饰性排列上(W3C 也明确把这条列为滥用 order 的问题)。
tabindex 的三种值
| 值 | 效果 | 用在 |
|---|---|---|
tabindex="0" | 加入 Tab 序,位置按 DOM 顺序 | 让一个本来不可聚焦的元素可聚焦(自定义控件、可横向滚动的容器) |
tabindex="-1" | 移出 Tab 序,但仍可被 JS 的 .focus() 聚焦 | 模态框容器、路由切换后要把焦点送过去的标题、临时禁用的项 |
tabindex="1" 及以上正数 | 抢到所有 0 和原生可聚焦元素之前,按数值升序 | 反模式,不要用 |
正数为什么是反模式:它创造了一个和 DOM 无关的全局顺序,任何人往页面里加一个新控件都会被它挤到后面去;多个组件各自用正数就会互相打架,且需要全站统一维护这套编号——实践中必然失控。需要调顺序,就去调 DOM。
永远不要裸写 outline: none
*:focus { outline: none } 是最有破坏性的一行 CSS:它让键盘用户彻底看不见焦点在哪,页面直接不可用。默认焦点环确实常常和设计不搭,但正确做法是替换而不是删除:
- 用
:focus-visible——浏览器按输入方式判断该不该显示焦点环:键盘 Tab 过来会显示,鼠标点击一般不显示(正是大家想删 outline 的那个场景)。所以现代写法是:focus-visible { outline: 2px solid …; outline-offset: 2px; }; - 务必保留
outline-offset或改用醒目的box-shadow,让焦点样式在深浅背景上都看得清; - 别用
outline: none+ 只改背景色来表示焦点——对比度往往不够,也过不了 WCAG 的焦点可见性要求。
Skip link 与模态框的焦点管理
- Skip link:页面第一个可聚焦元素做成一个跳到
#main的链接,平时用视觉隐藏藏起来,:focus时显示出来。它让键盘用户不必每次换页都 Tab 过几十个导航链接。注意目标<main id="main" tabindex="-1">要加tabindex="-1",否则部分浏览器只滚动而不真的移动焦点; - 打开模态框时:把焦点移进去(通常给对话框容器或它的标题),把焦点关在里面(Tab 到最后一个再按 Tab 应回到第一个),并让 Esc 能关闭;
- 关闭时把焦点还给原来那个触发按钮——这一步最常被忘。不还回去的话,焦点会掉回
<body>,用户下一次 Tab 会从整个页面的开头重新开始,完全迷失位置; - 能用
<dialog>+showModal()就用它:焦点陷阱、Esc 关闭、背景内容惰性化、::backdrop遮罩全部原生自带。手写的话,配合inert属性把背景内容整块排除出交互,比手动逐个设tabindex="-1"干净得多。
<!-- Skip link:页面第一个可聚焦元素,聚焦时才现身 -->
<a class="skip" href="#main">跳到主内容</a>
<nav>…几十个导航链接…</nav>
<main id="main" tabindex="-1">…</main>
/* 平时移出视野,:focus 时滑入 —— 不能用 display:none */
.skip { position: absolute; top: -3rem; left: 1rem; }
.skip:focus { top: 1rem; }
/* 焦点环:替换,不要删除 */
:focus-visible {
outline: 2px solid CanvasText;
outline-offset: 2px; /* 留白,深浅底都看得清 */
}
/* ❌ 绝对不要:*:focus { outline: none } */
<!-- 模态框:用原生 dialog,焦点陷阱 / Esc / 背景惰性全自带 -->
<dialog id="dlg">
<h2>确认删除?</h2>
<button id="ok">确定</button>
</dialog>
// 打开前记住是谁触发的,关闭后把焦点还回去
let opener = null;
btn.addEventListener("click", () => {
opener = document.activeElement;
dlg.showModal(); // 注意:show() 没有焦点陷阱
});
dlg.addEventListener("close", () => opener?.focus());
<!-- tabindex 三种值 -->
<div tabindex="0">加入 Tab 序,位置按 DOM 顺序</div>
<div tabindex="-1">不进 Tab 序,但 JS 可 focus()</div>
<div tabindex="1">❌ 正数:抢到所有人前面,反模式</div>outline: none 不加替代样式——头号罪状,见上文。② 用 CSS 改视觉顺序(
order、row-reverse)造成 Tab 顺序错乱,这个 bug 在鼠标下完全看不出来,只有拔了鼠标才暴露。③ 正数
tabindex:看似解决了当下的顺序问题,实际是把债留给了下一个往页面里加控件的人。④ 用
<div onclick> 模拟按钮:它不可聚焦、Enter/空格无反应、屏幕阅读器不播报为按钮。想补齐就得同时写 tabindex="0" + role="button" + keydown 里处理 Enter 和空格(空格还要 preventDefault() 阻止页面滚动)+ 自己处理 disabled 状态——写四行不如用一个 <button>。⑤ 模态框关闭后不归还焦点,用户位置丢失。
⑥ 用
opacity: 0 或 transform 移出视口来隐藏菜单:元素仍在 Tab 序里,用户会 Tab 到一个看不见的链接上,焦点凭空消失。真隐藏要用 display: none、visibility: hidden 或 inert。display: none 或 visibility: hidden 隐藏才会真正移出 Tab 序,仅用 opacity: 0 或把它挪出视口不会。ARIA 不添加任何行为,它只改变辅助技术看到的「这是什么、现在什么状态」。给 div 写上 role="button",它不会因此变得可聚焦、可用 Enter 触发——你只是让屏幕阅读器宣称它是按钮,而它其实不是。理解这一点,就理解了 ARIA 全部的用法和全部的危险。
ARIA 第一规则:能用原生就别用 ARIA
规范开篇的第一条使用规则是:如果存在一个原生 HTML 元素能提供你需要的语义和行为,就用它,不要挪用别的元素再加 ARIA。
<button>完胜<div role="button" tabindex="0">:前者白送可聚焦、Enter+空格触发、disabled 语义、表单提交能力;<nav>优于<div role="navigation">,<input type="checkbox">优于role="checkbox";- 大规模无障碍数据统计年年得出同一个结论:用了 ARIA 的页面平均错误数反而更高——因为 ARIA 用错的代价比不用更大。
ARIA 真正该出场的场合只有两类:① 原生没有对应元素的复合控件(标签页 tabs、树形视图、组合框);② 给原生元素补充状态和关系(aria-expanded、aria-current、aria-describedby)。
命名:三个属性怎么选
| 方式 | 怎么用 | 什么时候 |
|---|---|---|
<label> | 原生关联表单控件 | 表单控件的首选,永远优先 |
aria-label | 直接写一个字符串当名称 | 页面上没有可见文字可用:图标按钮、<nav aria-label="面包屑"> |
aria-labelledby | 指向页面上已有元素的 id(可写多个 id 用空格隔开,会拼接) | 名称已经作为可见文字存在:对话框用它指向自己的标题 |
优先级:aria-labelledby > aria-label > 原生(label / 元素内文字)。所以 aria-label 会盖掉元素自己的可见文字——按钮上写着「保存」却因为 aria-label="提交" 被读成「提交」,语音控制用户说「点击保存」就会失效。有可见文字时优先用 aria-labelledby 指过去,别另写一套。aria-describedby 是补充说明(如密码规则、错误提示),在名称之后播报,不替代名称。
状态与动态内容
aria-expanded="true/false"折叠展开态、aria-current="page"当前所在的导航项、aria-pressed切换按钮的按下态;这些值必须用 JS 与实际状态同步更新,写死一个 false 比不写更有害;aria-hidden="true"把元素从可访问性树移除(装饰性图标必配)。绝对不能加在可聚焦元素或其祖先上——会造出「能 Tab 到但屏幕阅读器读不出来」的鬼元素;- live region:
aria-live="polite"的容器内内容变化时会被播报(assertive打断当前朗读,只用于真正紧急的错误)。关键点:这个容器必须在页面加载时就存在于 DOM 里,之后再往里填内容;如果整个容器是后插入的,很多屏幕阅读器不会播报。
<!-- 图标按钮:用 aria-label 命名,图标对 AT 隐藏 -->
<button aria-label="关闭">
<i class="icon-x" aria-hidden="true"></i>
</button>
<button aria-expanded="false" aria-controls="menu">菜单</button>
<!-- 动态区域播报:polite 等空闲,assertive 立即 -->
<div aria-live="polite">已保存</div>
<!-- tabindex:0 让元素可 Tab 聚焦,-1 移出 Tab 序仅供 JS 聚焦 -->
<div tabindex="0" role="button">可聚焦控件</div>
<div id="dlg" tabindex="-1">弹窗打开后由 JS 聚焦</div>
<!-- ❌ 别用 div 模拟按钮(无键盘支持)-->
<div onclick="go()">提交</div>
<!-- ✅ 用真正的 button -->
<button onclick="go()">提交</button><ul> 加 role="presentation" 会同时抹掉它和它子项的列表语义,用户再也听不到「列表,共 5 项」。② role 和实际行为对不上:写了
role="button" 却没实现键盘触发,写了 aria-expanded 却从不更新——屏幕阅读器如实播报你声明的状态,用户据此做的判断全是错的,这比什么都不说更糟。③
aria-label 用在不支持命名的元素上无效:加在普通的 <div>、<span>、<p> 上(没有 role 的通用容器)通常不会被播报,很多人以为写了就生效。它对交互元素、地标元素、以及带 role 的元素才可靠。④ 最常见的无障碍硬伤其实根本不是 ARIA:历年自动化扫描的统计里,排前几位的一直是颜色对比度不足(正文需 ≥ 4.5:1,大字号 ≥ 3:1,WCAG AA)、图片缺 alt、表单缺 label、链接文字不明。先把这四件做对,效果远大于研究 ARIA。
⑤
title 属性不是可访问性方案:触屏无法触发、键盘用户拿不到、部分屏幕阅读器不读。别用它替代 label。role="tablist" 要同时正确实现 tab/tabpanel 的角色关系、aria-selected、左右方向键切换、Home/End 跳首尾——漏一条整个组件对屏幕阅读器用户就是坏的。能用 <details>、<dialog>、<select> 顶掉的,就别自己造。表单是「原生不够用、只好自己写」的重灾区:输入框跟着内容变宽、下拉菜单换个样子、校验提示别一上来就爆红——这三件事以前都得写脚本或者干脆重造控件。现在它们各自有一个属性或一个伪类。
field-sizing: content:输入框按内容伸缩
- 默认的
<input>宽度由size属性决定,和里面有几个字毫无关系;field-sizing: content让它按当前内容取宽,<textarea>则按内容取高(自动增高的输入框从此不需要那段「测量 scrollHeight 再赋值」的脚本); - (Chrome 150,默认字体):同一个
<input>默认 177px;加field-sizing: content后,值为ab时 22.8px、值为 22 个字符时 148px;改value后宽度立刻跟着变; - 一定要配
min-width/max-width(<textarea>配min-height/max-height):上面那个 22 字符的例子加上max-width: 12ch后停在 97px,不加就会一路撑下去; <textarea rows="1">加上它以后,三行内容的高度51px——rows变成了「最小高度」而不是固定高度。
可定制 <select>:appearance: base-select
- 原生下拉的选项列表由操作系统绘制,CSS 完全碰不到——这是「为什么所有 UI 库都自己重做了一个 Select」的唯一原因;
- 给
<select>和::picker(select)同时写appearance: base-select,整个下拉就变成可以用 CSS 排版的普通元素:<option>里能放图标和两行文字,::picker-icon是那个箭头,option:checked::before能加对勾; - 切换后
<select>的appearance计算值为base-select(原生是auto),<option>的display从浏览器内部值变成了flex——也就是说选项内部可以直接用弹性布局; - 它仍然是那个
<select>:键盘操作、表单提交、移动端行为、无障碍语义全部由浏览器负责,你只换了外观。这是自研 Select 永远给不了的。
:user-invalid:别一进页面就满屏红框
:invalid的问题在于它立刻就成立:一个required的空输入框,页面刚渲染出来就已经是:invalid(matches(":invalid")为true),照它写红边框,用户还没输入就被判错;:user-invalid只在用户真的交互过之后才成立——未交互时为false;连脚本改值加派发input事件再失焦也不算(仍是false),它认的是真实用户输入。写自动化测试时这一条会让你以为选择器写错了;- 配套的还有
:user-valid(交互后合格才亮绿)。规则:装饰性反馈用:user-*,提交前的拦截用checkValidity()。
inert:把一片区域整体「关掉」
- 加了
inert的元素及其后代不能被聚焦、不能被点击、也不出现在无障碍树里——做模态弹窗时用来屏蔽背景,比给每个元素加tabindex="-1"靠谱得多; - 两条边界:对区域内的按钮调
focus()确实聚不上(activeElement退回<body>),但直接调.click()事件照样触发——inert拦的是用户输入,不是脚本; - 另外它的
pointer-events计算值仍是auto,所以别指望靠读这个属性判断一个区域是否被禁用。
<!-- 跟着内容伸缩,但有上下限 -->
<input class="tag-input" value="前端">
<textarea class="autogrow" rows="1"></textarea>
<!-- 换外观但还是原生 select -->
<select class="pretty">
<option>草稿</option>
<option>已发布</option>
</select>
<!-- 弹窗打开时屏蔽背景 -->
<main inert>…</main>
.tag-input {
field-sizing: content;
min-width: 4ch;
max-width: 20ch; /* 不写上限就会一路撑破布局 */
}
.autogrow {
field-sizing: content;
min-height: 3lh; /* lh = 一个行高,正好三行起 */
max-height: 10lh;
}
/* 可定制下拉:两处都要写 */
.pretty, .pretty::picker(select) { appearance: base-select; }
.pretty::picker-icon { color: var(--color-muted); transition: .2s; }
.pretty:open::picker-icon { rotate: 180deg; }
.pretty option:checked::before { content: "✓ "; }
/* 交互过才提示,别一进来就红 */
input:user-invalid { border-color: var(--color-danger); }
input:user-valid { border-color: var(--color-ok); }validationMessage 给的是「请在电子邮件地址中包括“@”。“still bad”中缺少“@”。」,换台英文系统就是另一串字。要自己控制文案就用 setCustomValidity() 或干脆自己渲染错误行,别写死在测试断言或设计稿里。另一处:
field-sizing: content 把 placeholder 也算进宽度——一个空值但带较长占位文字的输入框宽 114.6px,远大于内容为 ab 时的 22.8px。于是「空框很宽、一打字反而缩小」,看着像 bug。占位文字要么写短,要么把 min-width 定住。第三个:
appearance: base-select 只写在 <select> 上不够,::picker(select) 也要写,否则弹出的列表还是系统绘制的那个,你的样式一条都不生效。3lh / 10lh 这个单位值得单独记一笔:lh 就是当前元素的一个行高(rlh 是根元素的),所以「最少三行、最多十行」可以直接写成尺寸,不用把 line-height 的数字在 CSS 里手算一遍。它和 ch(一个 0 字符的宽度)是写输入框尺寸时最顺手的两个单位——max-width: 20ch 的意思就是「大约二十个字符」,比 px 更贴近你真正想表达的约束(单位全表在 05 章)。CSS 基础
CSS 用规则描述元素的呈现。这一章管的是「样式为什么生效、为什么不生效」的底层机制:规则怎么写、样式表怎么进页面、多条规则冲突时层叠如何裁决、哪些值会往下继承、长度单位各自相对谁,以及自定义属性这套运行时的值机制。把这五张卡吃透,后面所有「明明写了却没用」的困惑都能自己排掉。
CSS 的语法只有一个形状:选择器 + 声明块,声明块里是若干条 属性: 值;。真正需要做判断的是怎么把样式送进页面——三种引入方式在缓存、复用和加载性能上差别很大。
一条规则的解剖
- 选择器决定「作用于谁」(下一章专讲),声明块决定「长什么样」;
- 声明之间用
;分隔,最后一条的分号可以省但别省——后面追加一行时最容易在这里出错; - 浏览器对看不懂的声明会整条丢弃而不是报错:属性名拼错、值的类型不对,都只是静默失效。这就是「样式没生效」第一位的排查方向——打开 DevTools 看那条声明有没有被划掉(怎么打开见 01 章)。
三种引入方式怎么选
| 方式 | 写法 | 能被缓存 / 复用 | 什么时候用 |
|---|---|---|---|
| 外部样式表 | <link rel="stylesheet"> | 能,多页共享同一份 | 默认选它,项目的主样式 |
| 内部样式 | <style> | 不能,随 HTML 一起下载 | 首屏关键 CSS、单文件 demo |
| 行内样式 | style="…" | 不能,且无法复用 | 只放运行时算出来的值 |
行内样式还有一个硬限制:它是「一串声明」而不是规则,写不进伪类和媒体查询——style="color:red" 没办法表达 :hover。所以它天然只适合装 JS 算出来的一次性数值(拖拽坐标、动态高度)。
CSS 是渲染阻塞资源
- 浏览器在样式表下载并解析完之前不会绘制页面——否则用户会先看到一屏没样式的裸 HTML(FOUC)。所以
<link>放<head>里、且要控制体积; - 连带效应:脚本也会被待处理的样式表挡住。因为脚本可能读取元素的计算样式,浏览器必须先算完样式才敢执行它;
- 只在特定条件下才需要的样式,用
media属性拆出去:<link rel="stylesheet" href="print.css" media="print">仍会被下载,但不阻塞渲染。
/* 一条规则的结构 */
.card { /* 选择器 */
color: #333; /* 声明 = 属性: 值 */
font-size: 1rem;
colour: red; /* 拼错 → 整条静默丢弃,不报错 */
}
/* HTML 里的三种引入 */
// <link rel="stylesheet" href="style.css"> 外部 ✅ 默认选它
// <style> p { color: red } </style> 内部(首屏关键 CSS)
// <p style="color:red"> 行内(只放 JS 算出的值)
/* 按条件拆分:仍会下载,但不阻塞首屏渲染 */
// <link rel="stylesheet" href="print.css" media="print">
/* @import 必须写在所有规则之前(@charset / @layer 语句除外) */
@import "theme.css"; /* ⚠️ 会串行加载,生产环境避免 */@import 串样式表。它和 <link> 的关键差别在于发现时机:<link> 在 HTML 解析阶段就被看见、可以并行下载;而 @import 藏在 CSS 文件内部,浏览器必须先把外层样式表下载并解析到那一行,才知道还有个文件要拿——层层 @import 就变成一条串行的请求链,每一层都多一个完整往返。要拆分文件请交给构建工具在打包时合并(@import 在源码里随便用,产物里不该剩下)。另外 @import 的位置有硬性规定:必须出现在所有普通规则之前(只允许 @charset 和 @layer 语句在它前面),写到中间会被整条忽略——而且同样是不报错的那种忽略。<style>,其余全部走 <link>——前者省掉一次往返、让首屏立刻有样式,后者吃到跨页缓存。行内 style 不是「不能用」,而是只该装 JS 算出来的值(元素跟随鼠标的坐标、依据内容测出的高度);凡是设计稿里写死的样式都应该有个类名。顺带记住它的代价:行内样式在层叠里排在特异性之前裁决,一旦写上,任何选择器都盖不掉(见下一卡)。「Cascading Style Sheets」的第一个词就是层叠:同一个属性被多条声明命中时,由一套分步裁决的流程选出赢家。特异性只是这套流程中间的一步——把它当成全部,正是绝大多数「优先级玄学」的来源。
层叠按顺序过这几关,前一关分出胜负就不看后面
- 来源与重要性(origin & importance):声明来自浏览器默认样式表、用户样式表还是作者样式表,以及带不带
!important; - 封装上下文:涉及 Shadow DOM 时才有意义,日常写页面碰不到;
- 内联样式:元素
style属性里的声明,胜过任何样式表规则里的普通声明; - 层叠层
@layer:后声明的层赢过先声明的层,未分层的样式赢过所有层——这是普通声明的顺序,!important声明的层序完全相反(见 12 章); - 特异性:下面细讲;
- 源码顺序:以上全部打平,后写的赢。
请注意第 3 步的位置:内联样式是在比特异性之前就赢了——这解释了那个经典困惑「为什么我堆了五层选择器还是盖不掉 style=""」。答案不是「它的权重是 1000」,而是它根本没参加特异性这场比较,唯一能翻盘的手段是抬到更高的重要性档(!important)。
特异性是一个三元组 (A, B, C)
- A = 选择器里 ID 选择器的个数;
- B = 类选择器 + 属性选择器 + 伪类的个数;
- C = 元素(类型)选择器 + 伪元素的个数;
- 比较规则是先比 A,A 相同再比 B,再比 C——逐位比较、绝不进位。所以 (0, 20, 0) 依然输给 (1, 0, 0):再多的类也换不来一个 ID;
- 通配符
*和组合器(空格>+~)不计分。
| 选择器 | A | B | C | 说明 |
|---|---|---|---|---|
* | 0 | 0 | 0 | 通配符不计分 |
li | 0 | 0 | 1 | 一个元素选择器 |
ul > li | 0 | 0 | 2 | 组合器不计分 |
li::marker | 0 | 0 | 2 | 伪元素记在 C |
.nav | 0 | 1 | 0 | 类 |
[type="text"] | 0 | 1 | 0 | 属性选择器同类 |
a:hover | 0 | 1 | 1 | 伪类记在 B |
#main | 1 | 0 | 0 | ID |
#main .nav li | 1 | 1 | 1 | |
:where(#main) .nav | 0 | 1 | 0 | :where() 把参数归零 |
:is(#main, .side) li | 1 | 0 | 1 | :is() 取参数中最高的 |
li:not(.done) | 0 | 1 | 1 | :not() 自身不计分,参数计分 |
!important 做的是「换档」,不是「加分」
- 它把一条声明从「普通」提到「重要」档,而档位属于第 1 步——所以它凌驾于全部特异性之上,也能盖住内联样式(内联样式自己也可以写
!important,那就再高一档); - 更反直觉的是重要声明之间的顺序是反过来的:普通声明的优先级是「浏览器默认 < 用户 < 作者」,而带
!important时变成「作者 < 用户 < 浏览器默认」。也就是说用户自己写的!important能压过网站作者的!important; - 这不是 bug,是为可访问性留的最终否决权:用户强制放大字号、强制高对比配色时,网站无权反悔。理解了这条,就能明白
!important的设计初衷根本不是给业务代码抢优先级用的。
控制特异性的正规手段
:where(…)的特异性恒为 0——写默认样式 / reset 时把选择器包进去,使用者随手一个类就能覆盖;:is(…)/:not(…)/:has(…)取参数里最高的那个的特异性;- 真正的结构性答案是
@layer:它在特异性之前裁决,让你按「reset → 第三方库 → 组件 → 工具类」显式排序,从此不必靠堆选择器提权——详见 12 章。
/* 特异性逐位比较,不进位 */
#main p { color: green; } /* (1,0,1) 胜 */
.a.b.c.d.e.f.g.h p { color: red; } /* (0,8,1) 输 */
/* 打平时后写的赢 */
.btn { color: blue; }
.btn { color: red; } /* (0,1,0) 打平 → red 生效 */
/* 内联样式在「比特异性之前」就赢了 —— 加多少选择器都没用 */
// <p id="x" class="a" style="color: purple">…</p>
html body #x.a { color: green; } /* ❌ 仍然是 purple */
html body #x.a { color: green !important; } /* ✅ 换档才行 */
/* :where() 归零:写默认值的正确姿势 */
:where(.prose h2) { margin-block: 1.5em .5em; } /* (0,0,0) */
.tight { margin-block: 0; } /* (0,1,0) 轻松覆盖 */
/* :is() 取参数中最高的一档 —— 注意它会「传染」 */
:is(#app, .page) .btn { } /* (1,1,0),按 #app 算 */
:where(#app, .page) .btn { } /* (0,1,0),多数时候这才是你要的 */
/* @layer 在特异性之前裁决:后声明的层赢 */
@layer reset, components;
@layer reset { #specific .deep .btn { color: gray; } } /* (1,2,0) 却输 */
@layer components { .btn { color: red; } } /* (0,1,0) 却赢 */① 它不是四位数,也不能进位。网上流传的「行内 1000 / ID 100 / 类 10 / 元素 1」算法在极端情况会给出错误答案——按那套算法 11 个类是 110,就该赢过一个 ID 的 100,但实际上 (0,11,0) 永远输给 (1,0,0)。请直接按三元组逐位比。
②
:not() / :is() 自己不计分,但参数计分。li:not(.done) 是 (0,1,1) 不是 (0,0,1);更阴的是 :is(#app, .page) .btn——实际命中往往靠 .page,特异性却按 #app 算成 (1,1,0),之后你想用一个类覆盖它就会莫名其妙失败。要「多选一」又不想涨特异性,换成 :where()。③ 特异性只在「同一属性、同一元素」上比。它跟继承不是一回事:给
body 写 (0,0,1) 的 color,会被子元素上任何一条直接命中该子元素的 color 声明覆盖——哪怕后者特异性是 (0,0,0)。直接命中永远优先于继承来的值,两者根本不参与同一场比较。:where() 主动归零,别让 reset 反过来压住业务代码;③ 需要提权时,先想 @layer,再想调整源码顺序,最后才是 !important。一旦项目里出现「靠再加一个祖先选择器来打赢」的提交,就该停下来看架构了——这正是 Tailwind(13 章)用「一个类 = 一条声明 + 统一层」绕开的整个问题。给 body 设一次 font-family,全站文字都变了;给 body 设 border,却只有 body 自己有边框。这不是随机的——会继承的属性,几乎都是描述「文本怎么呈现」的。
继承与否有一条清晰的直觉
- 继承的:文本与排版相关——
color、font-*、line-height、letter-spacing、word-spacing、text-align、text-indent、text-transform、white-space、direction、list-style-*、cursor、visibility,以及所有自定义属性(下一卡); - 不继承的:盒子自身的几何与装饰——
width/height、margin/padding/border、background、display、position、overflow、z-index; - 道理很直白:文本呈现天然应该在一段文字里保持一致,所以往下传;而「多宽多高、离别人多远」是每个盒子自己的事,继承下去只会一团乱——想象一下
border会继承:给body加个边框,页面上每个元素都会套一圈框。
五个全局值:任何属性都能接受它们
| 值 | 回到哪 | 典型用途 |
|---|---|---|
inherit | 父元素该属性的计算值 | 让本不继承的属性强制继承,如 button { font: inherit } |
initial | 该属性在规范里定义的初始值 | 清掉某个具体属性;注意这不是「浏览器默认样式」 |
unset | 继承属性 → 同 inherit;非继承属性 → 同 initial | 「就当这条没写过」,配 all: unset 剥光一个元素 |
revert | 回退到上一个来源的值(作者 → 用户 → 浏览器默认) | 真正的「恢复浏览器默认样式」 |
revert-layer | 回退到上一个层叠层的值(同一来源内) | 在 @layer 里撤销本层的覆盖,见 12 章 |
还有一个搭配它们用的属性 all:它一次性代表几乎所有属性(direction 和 unicode-bidi 除外),所以 all: revert 就是「把这个元素上作者写的样式全部撤掉、退回浏览器默认」,做第三方内容沙箱时很有用。
initial ≠ 浏览器默认样式
initial取的是规范给该属性定义的初始值,跟这个元素平时长什么样无关。最容易踩的是display:它的初始值是inline,所以给一个<div>写display: initial,得到的是行内元素,而不是「回到 block」;<div>之所以是块级,是浏览器默认样式表里写了div { display: block }——要退回那一层,得用revert。
/* 文本类属性自动往下传 */
body {
font-family: system-ui, sans-serif;
line-height: 1.6;
color: #333; /* 后代默认全都跟着变 */
}
/* 表单控件是例外:UA 样式表给了它们自己的字体,不跟随 body */
input, button, select, textarea {
font: inherit; /* reset 必备的一行 */
color: inherit;
}
/* 让「不继承」的属性强制继承 */
a { color: inherit; } /* 标题里的链接不再变蓝 */
/* initial 取的是「规范初始值」,不是「浏览器默认」 */
.oops { display: initial; } /* ⚠️ div 会变成 inline! */
.right { display: revert; } /* ✅ 退回 UA 样式表的 block */
/* 把按钮剥成一张白纸,再自己画 */
.bare-btn {
all: unset; /* 继承属性→继承,其余→初始值 */
cursor: pointer;
}line-height 上会真正咬人:写 line-height: 150% 或 1.5em 时,父元素会先按自己的字号算成一个具体长度(比如 24px)再传下去——子元素哪怕字号只有 12px,行高也还是 24px,看起来行距大得不成比例。而写 无单位的 line-height: 1.5 时,传下去的是倍数本身,每个后代各自乘以自己的字号,才是你想要的效果。所以 line-height 一律写无单位值。另一个反直觉点:继承不看特异性,也拦不住直接命中。子元素上任何一条命中它自己的
color 声明(哪怕来自 *{},特异性 0)都会赢过从父元素继承来的值——这就是为什么 * { color: … } 这种通配 reset 会把整棵树的继承链全部打断,写 reset 时要格外小心通配符。input / button / select / textarea { font: inherit } 应该出现在每个项目的 reset 里——表单控件的字体来自浏览器默认样式表而非继承,不写这行,你的表单永远和正文不是一套字。② 想「抹掉一个元素的既有样式再从头画」时,all: unset(当白纸)和 all: revert(退回浏览器默认)是两个不同的意图,别搞混:给按钮做自定义外观用前者,给用户投稿的富文本做隔离用后者。另外 all: unset 会一并去掉 cursor: pointer 和焦点轮廓,记得补回来——尤其是焦点轮廓,去掉不补是无障碍事故。CSS 里几乎每个长度都能用好几种单位表达,选错不会报错、只会在别人的屏幕上出错。挑单位的唯一判据是:这个值应该跟着什么变?跟着用户字号,跟着组件字号,跟着容器,还是根本不该变。
常用单位对照
| 单位 | 相对于什么 | 典型用途 | 要留神的地方 |
|---|---|---|---|
px | 绝对长度(CSS 像素) | 边框、阴影、圆角 | 用在字号上会无视用户的浏览器字号设置 |
rem | 根元素 <html> 的字号 | 字号、间距、断点 | 全站唯一基准,最可预测——默认选它 |
em | 当前元素的字号(用在 font-size 上时是父元素的) | 组件内部跟随字号缩放的 padding | 会层层累乘 |
% | 看属性而定,见下方警告 | 宽度、居中偏移 | 纵向 padding/margin 也相对宽度 |
ch | 当前字体里 0 字符的推进宽度 | max-width: 65ch 控制正文行长 | 是近似值,中文下不直观 |
vw / vh | 视口宽 / 高的 1% | 全屏区块、流式字号 | 移动端 vh 有地址栏问题 |
dvh / svh / lvh | 动态 / 最小 / 最大视口高度 | 移动端全屏 | 移动端优先用 dvh,展开见 11 章 |
fr | Grid 轨道里剩余空间的一份 | grid-template-columns: 1fr 2fr | 只在 Grid 轨道定义里有意义,别处用是无效值 |
clamp(a, b, c) | 不是单位,是函数 | 流式字号 / 间距,带上下限 | 中间那项通常放视口相关的值 |
rem 还是 em:看这个值该跟着谁
rem只认根字号,写在哪一层结果都一样——所以字号、间距、圆角、媒体查询断点全用它,改一处就能整站缩放;em认的是「当前元素的字号」,价值在于组件内部的比例关系:按钮的padding: .5em 1em会自动跟着按钮字号走,做大中小三档只需要改font-size一行;- 它的代价是累乘:嵌套的
<li>层层写font-size: 1.2em,第三层就变成 1.728 倍。所以em只用在「相对自身字号」的属性上(padding、margin、border-radius),尽量别用在font-size上——那正是累乘的源头。
两条最常被忽略的规则
- 字号别用
px。用户可以在浏览器设置里调大默认字号(这是低视力用户的主要辅助手段),根字号会随之变化,rem会跟着走——但html { font-size: 16px }会把这个设置直接锁死。把根字号留给浏览器默认(或写100%/ 百分比),是最省事的一次无障碍改善; - 纵向的百分比 padding / margin 相对的是包含块的宽度。
padding-top: 50%不是「父高的一半」而是「父宽的一半」——这条规则曾被用来 hack 出固定宽高比,今天已经有aspect-ratio了,但当你莫名其妙得到一大块纵向留白时,多半就是它。
/* 别给 html 写死 px —— 那会锁掉用户的字号设置 */
html { /* 留空即用浏览器默认,通常 16px */ }
.card {
font-size: 1.125rem; /* 相对根:写在哪层结果都一样 */
padding: 1rem;
border: 1px solid; /* 发丝线用 px 才精确 */
max-width: 65ch; /* 正文行长的经验上限 */
}
/* em 的正确用法:组件内部的比例,改字号就整体缩放 */
.btn { font-size: 1rem; padding: .5em 1em; border-radius: .25em; }
.btn--lg { font-size: 1.25rem; } /* padding/圆角自动跟着变大 */
/* ⚠️ em 用在 font-size 上会层层累乘 */
li li { font-size: 1.2em; } /* 第三层已是 1.728 倍 */
/* 移动端全屏用 dvh,不要 100vh(地址栏收起时会溢出) */
.hero { min-height: 100dvh; }
/* clamp(最小, 理想, 最大):流式且有边界 */
h1 { font-size: clamp(1.75rem, 4vw + 1rem, 3rem); }
/* ⚠️ 纵向百分比相对的是「宽度」 */
.ratio { padding-top: 56.25%; } /* 老式 16:9 hack */
.ratio-modern { aspect-ratio: 16 / 9; } /* ✅ 今天该这么写 */100vh 在移动端会溢出一屏。vh 按的是「地址栏收起时」的大视口高度,而页面刚加载时地址栏通常是展开的——于是 height: 100vh 的首屏比实际可视区高出一截,底部按钮被顶到屏幕外,用户还得先滚一下。这就是 dvh(随地址栏伸缩动态变化)、svh(地址栏展开时的小视口)、lvh(地址栏收起时的大视口,等于旧的 vh)存在的原因,移动端全屏一律用 100dvh。另外两个小陷阱:
fr 出了 Grid 轨道就是无效值(width: 1fr 会被整条丢弃,且不报错);calc() 里的 + 和 - 两侧必须有空格——calc(100%-20px) 会被当成「100% 后面跟着一个负长度」而解析失败,* 和 / 则没有这个要求,这个不对称非常反直觉。rem,组件内部比例用 em,边框和阴影用 px,Grid 轨道用 fr,需要随视口连续变化的值用 clamp()。clamp() 里中间那项别只写 4vw——纯视口单位在极窄屏上会缩得过分,写成 4vw + 1rem 这样「视口分量 + 固定底座」的形式,斜率更平缓、也保住了对用户字号的响应。视口单位本身的坑(vh 与移动端地址栏、vw 与滚动条宽度)在 11 章展开。以 -- 开头的名字是自定义属性,用 var() 读回来。它常被叫作「CSS 变量」,但这个叫法容易让人拿 Sass 变量去类比——两者的本质完全不同:Sass 变量在编译期就被替换掉、产物里根本不存在;自定义属性是货真价实的 CSS 属性,会参与层叠、会继承、在浏览器里一直活着。这一卡讲的是它的语言机制;拿它搭设计令牌与主题体系是 12 章的事。
声明与读取
- 声明就是普通声明:
--gap: 1rem;——注意大小写敏感(--Gap和--gap是两个属性),这一点和其它 CSS 属性名不同; - 读取用
var(--gap),第二个参数是兜底值:var(--gap, 1rem); - 兜底值是「第一个逗号之后的全部内容」,所以它自己可以带逗号:
var(--font, Helvetica, Arial, sans-serif)的兜底是整串字体栈; var()可以嵌套、可以出现在calc()里,也可以只提供值的一部分:box-shadow: 0 0 0 3px var(--ring-color)。
它会继承——这是它最有用的性质
- 自定义属性是继承属性:在
:root上定义一次,整棵树都能读到; - 反过来,在任意子树上重新赋值就等于局部换肤——同一个
.card { background: var(--brand) }规则,放进.danger容器里就自动变成红色,不需要再写一条规则; - 这也意味着它和普通属性一样服从层叠:特异性更高的规则里的
--brand会赢。
它是运行时的
- 媒体查询里能改:
@media (min-width: 48rem) { :root { --gap: 2rem } }——一处赋值,所有引用它的地方一起变; - 属性选择器里能改:
[data-theme="dark"] { --bg: #111 },切主题只改根元素上一个属性; - JS 里能读能改:
el.style.setProperty('--x', v)/getComputedStyle(el).getPropertyValue('--x'),改完浏览器自动重算所有引用点; - 这三件事 Sass 变量一件都做不到——它在浏览器拿到 CSS 之前就已经消失了。需要「运行时能变」就必须用自定义属性。
@property:注册类型之后才能动画
- 没注册的自定义属性,其值是一段任意 token 序列,浏览器并不知道
--angle: 0deg里的0deg是个角度——所以它无法在两个值之间插值,transition只会在中途一次性跳变; @property给它登记类型(syntax)、是否继承(inherits)和初始值(initial-value)。登记成<angle>/<color>/<length>之后,浏览器才知道怎么算中间值,transition和@keyframes就能平滑过渡了;- 典型用例:让渐变角度动起来、让渐变的某一个色标动起来——这些用普通属性根本表达不了。
:root {
--brand: oklch(0.55 0.18 280);
--gap: 1rem;
}
.card {
background: var(--brand);
padding: var(--gap);
border-radius: var(--radius, 8px); /* 第二个参数是兜底 */
}
/* 会继承 → 在子树上重新赋值就是局部换肤,规则一条都不用加 */
.danger { --brand: oklch(0.55 0.2 25); }
/* 运行时可变:媒体查询 / 属性选择器 / JS 都能改 */
@media (min-width: 48rem) { :root { --gap: 2rem; } }
[data-theme="dark"] { --brand: oklch(0.7 0.15 280); }
// JS: el.style.setProperty('--gap', '3rem')
/* ❌ var() 拼不出属性名,也拼不出标识符 */
// background: var(--x)-color; → 两个 token,不会拼成 background-color
// var(--prop): red; → 左边不能是 var()
/* 注册类型之后才能被动画 */
@property --angle {
syntax: "<angle>";
inherits: false;
initial-value: 0deg;
}
.ring {
background: conic-gradient(from var(--angle), red, blue, red);
transition: --angle .4s; /* 未注册的话这里只会跳变 */
}
.ring:hover { --angle: 360deg; }①
var() 是「值层面」的代换,拼不出标识符。background: var(--x)-color 不会拼成 background-color,--x 也不能拿来当属性名、选择器或 @media 的条件用。它替换进去的是一串 token,不参与词法拼接——所以 var(--size)px 同样不成立(要写 calc(var(--size) * 1px))。② 变量的值在声明时不校验,出错的时机和后果都和普通声明不一样。自定义属性接受几乎任意 token 序列,所以
--gap: 1rmm; 这条声明本身是合法的,浏览器照收不误;错误要等到 padding: var(--gap) 真正代换时才暴露。而此时的处理方式是 invalid at computed-value time——它不会像普通的语法错误那样「当作没写过、回落到前一条规则」,而是让该属性取继承值(如果是继承属性)或初始值(如果不是),效果等同于 unset。实战后果是:你以为写坏了会退回上一条样式,实际上是整个属性被清成初始值,页面可能直接塌掉,而 DevTools 里那条声明看着还是「有效」的。这也是 @property 的隐藏价值——注册过 initial-value 的属性,值非法时会退回你指定的初始值,而不是清空。:root——组件私有的值(--btn-padding)定义在组件类上,只有真正全局的令牌才上 :root,否则命名很快会撞车。② 需要在运行时变的用自定义属性,只在编译期决定的可以留给 Sass 变量;两者不是替代关系,混着用很正常。③ 拿它做「一个开关控制多处」是最高价值的用法——比如定义 --ring: 0,在 box-shadow 和 outline-width 里都乘上它,聚焦时只改这一个数。设计令牌的命名分层(原始值 → 语义值)和主题化的完整方案见 12 章。CSS 选择器
选择器决定一条规则作用于谁。这一章从基础选择器讲到组合器、伪类与伪元素,再单独用一张卡讲 :is()/:where()/:not() 这组「函数式伪类」——它们既能把重复的选择器列表收拢成一条,又是控制特异性的正规开关。精准选中目标,是写出低耦合、好覆盖的样式的前提。
五种基础选择器覆盖了日常九成的场景,真正被低估的是属性选择器——它能直接按 HTML 上的状态和数据挑元素,很多时候比额外挂一个类干净得多。
五种基础选择器
p元素(类型)选择器:按标签名选,特异性 (0,0,1),适合写全局的排版基线;.card类选择器:日常绝对主力,(0,1,0),一个元素可以挂多个类;#headerID 选择器:(1,0,0),不建议用来挂样式——特异性太高,后面想覆盖就得跟着堆;ID 留给锚点跳转、<label for>和 JS 抓取;*通配符:不计分 (0,0,0),主要用在box-sizing这类全局重置上;[attr]属性选择器:(0,1,0),和类同档。
属性选择器的七个运算符
| 写法 | 含义 | 例子 |
|---|---|---|
[attr] | 存在该属性(不管值) | [disabled]、[data-open] |
[attr="v"] | 值完全等于 v | [type="checkbox"] |
[attr~="v"] | 值是空格分隔的词表且含 v | [rel~="noopener"] |
[attr|="v"] | 值等于 v,或以 v- 开头 | [lang|="zh"] 命中 zh/zh-CN |
[attr^="v"] | 以 v 开头 | [href^="https"] |
[attr$="v"] | 以 v 结尾 | [href$=".pdf"] |
[attr*="v"] | 含 v 这个子串 | [href*="youtube"] |
在 ] 之前还能加一个标志位:i 表示忽略大小写匹配,s 表示强制区分大小写。[href$=".PDF" i] 才能同时命中 .pdf 和 .PDF。
它最好用的地方:直接读状态,不用同步类名
- 表单状态本来就在 DOM 上:
[disabled]、[readonly]、[required]、[aria-expanded="true"]、[aria-current="page"]——直接选它,比让 JS 再额外 toggle 一个.is-disabled类少一份要维护的真相; - 自定义状态用
data-*:[data-state="loading"]。JS 只写el.dataset.state = 'loading',样式和无障碍语义同时到位; - 这条思路的价值是让样式跟着可访问性属性走——你被迫把
aria-*写对,否则样式就不生效。
p { } /* 元素 (0,0,1) */
.card { } /* 类 (0,1,0) 日常主力 */
#header { } /* ID (1,0,0) 别拿来挂样式 */
* { } /* 通配 (0,0,0) */
/* 属性选择器的七个运算符 */
[disabled] { opacity: .5; } /* 存在 */
[type="checkbox"] { } /* 完全等于 */
[rel~="noopener"] { } /* 词表里含这个词 */
[lang|="zh"] { } /* zh 或 zh-开头 */
[href^="https"] { } /* 开头 */
[href$=".pdf" i] { } /* 结尾,i = 忽略大小写 */
[href*="youtube"] { } /* 含子串 */
/* 直接读 DOM 上已有的状态,不必再同步一个类 */
[aria-current="page"] { font-weight: 600; }
[data-state="loading"] { cursor: progress; }
/* 需要用 ID 当钩子、又不想要 (1,0,0) 的特异性 */
[id="main"] { } /* 只算 (0,1,0) */[class*="btn"] 是子串匹配,误伤范围比你想的大得多。它会命中 btn、btn-lg,但也会命中 subtn、button-group——只要 class 属性这个字符串整体里出现过 btn 就算,而且是拿整个 class 属性值去搜,所以 class="card btn-x" 里连空格两边都能跨。要表达「class 列表里含 btn 这个词」,正确的运算符是 ~=——而 [class~="btn"] 恰好完全等价于 .btn,所以直接写 .btn 就行。还有一处:属性值的匹配默认区分大小写。
[href$=".PDF"] 匹配不到 report.pdf。(HTML 文档里有一小批枚举型属性——比如 type——按规定是不区分大小写匹配的,但别指望记住这份名单,凡是值可能大小写不一的场景一律显式加 i 标志。)aria-expanded、disabled、data-state 这些属性本来就得写(前两个是无障碍要求),让 CSS 直接选它们,就消灭了「JS 忘了同步 .is-open 类导致样式和实际状态对不上」这一整类 bug。② 确实需要按 ID 选、又不想背上 (1,0,0) 的特异性时,改写成 [id="main"]——匹配结果完全一样,特异性却降到和类同档 (0,1,0),后续覆盖轻松得多。同理,:where(#main) 能直接归零,见本章最后一卡。组合器描述元素之间的结构关系。CSS 只有四个常用组合器,它们本身不计特异性,但选哪一个直接决定了样式的「作用半径」——这是组件之间会不会互相污染的分水岭。
四个组合器
| 写法 | 名称 | 选中的是 |
|---|---|---|
A B | 后代 | A 内部任意层级的 B |
A > B | 子 | A 的直接子元素 B |
A + B | 相邻兄弟 | 紧跟在 A 后面的那一个 B |
A ~ B | 通用兄弟 | A 之后所有同级的 B |
注意后两个的共同前提:必须是同一个父元素下的兄弟,而且只能向后看——CSS 里没有「前一个兄弟」组合器,也没有「父元素」组合器(后者由 :has() 补上了,见下一卡)。
后代还是子:意图与代价都不同
- 意图上,
>说的是「我知道这一层的结构」,空格说的是「不管埋多深我都要」。写组件样式时前者更安全:.card p会连带命中嵌进这张卡片里的另一个组件的段落,.card > p不会; - 代价上,浏览器匹配选择器是从右往左的——遇到
.menu a,引擎先把页面上所有<a>找出来,再逐个沿祖先链向上验证有没有.menu。右端越泛(a、*、div)候选集越大;>至少把向上验证限制成一步; - 不过现代引擎的选择器匹配已经足够快,不要为「性能」去牺牲可读性——真正的收益在意图清晰、作用半径可控上。真要优化,最有效的一招是让右端更具体(用类名结尾而不是标签名结尾)。
兄弟组合器的两个高价值用法
- 纯 CSS 交互:把
<input type="checkbox">/type="radio"藏起来,用:checked ~ .panel或:checked + .label控制后面元素的样式——手风琴、标签页、开关都能零 JS 实现。前提是勾选框在 DOM 里必须排在被控制元素前面且是它的兄弟; - 「猫头鹰」间距:
.stack > * + *读作「除第一个之外的每一个直接子元素」,给它加margin-block-start,就得到一个「元素之间有间距、首尾不多余」的堆栈——不用给每个子元素挂类,也不用最后一个再:last-child清零。今天更常用gap,但在不能建立 flex/grid 容器(比如富文本正文)的场合,这仍是最干净的写法。
.menu a { } /* 后代:任意层级的 a */
.menu > li { } /* 子:仅直接子 li */
h2 + p { } /* 相邻兄弟:紧跟 h2 的那一个 p */
h2 ~ p { } /* 通用兄弟:h2 之后所有同级 p */
/* 作用半径:这两条的差别就是组件会不会互相污染 */
.card p { } /* ⚠️ 嵌进卡片的其它组件的 p 也受影响 */
.card > p { } /* ✅ 只管自己这一层 */
/* 「猫头鹰」:除第一个外,每个子元素上方加间距 */
.stack > * + * { margin-block-start: 1rem; }
/* 纯 CSS 折叠面板:勾选框必须排在被控元素前面且同级 */
// <input type="checkbox" id="t" class="toggle">
// <label for="t">展开</label>
// <div class="panel">…</div>
.toggle { position: absolute; opacity: 0; }
.panel { display: none; }
.toggle:checked ~ .panel { display: block; }
/* ❌ CSS 没有「前一个兄弟」组合器 —— 只能反过来用 :has() */
.label:has(+ input:invalid) { color: crimson; }+ 和 ~ 只能向后看,而且不跨父元素。这两条限制加起来,导致很多看似简单的需求写不出来:「给最后一个之外的元素加下边框」得反过来写成 li:not(:last-child);「输入框非法时把它前面的 label 标红」在 :has() 出现之前根本没有纯 CSS 解法(现在写 .label:has(+ input:invalid))。另外 h2 ~ p 只命中同一个父元素下的 p,一旦某个 p 被包进了 <div>,它就不再是 h2 的兄弟了,规则静默失效——「明明在后面为什么没生效」十有八九是中间多了一层包裹。还有一个纯 CSS 交互的隐蔽陷阱:用
opacity: 0 或 position: absolute 隐藏那个 checkbox 是对的,但不能用 display: none——被 display: none 的表单控件无法接收键盘焦点,整个交互对键盘用户就废了。>,写全局排版基线(正文、富文本区)才用空格。理由是组件会被互相嵌套,而排版基线本来就该穿透到底。另外,看到自己在写 .a .b .c .d 这种四层后代链时停一下——它既脆(DOM 改一层就断)又难覆盖(特异性 (0,4,0)),把中间层去掉、或者干脆给目标元素起个类名,几乎总是更好的选择。这也是 BEM 一类命名法存在的理由,见 12 章。伪类选中的是元素处在某种状态或某个结构位置时的样子——这些信息 HTML 里没有对应的属性可写,只能由浏览器实时判断。用好它们,很多交互根本不需要 JS 介入。
状态伪类
- 交互:
:hover、:active、:focus、:focus-visible(浏览器按输入方式决定该不该显示焦点环:键盘导航时命中,鼠标点击按钮/链接时一般不命中,但点进文本输入框仍会命中)、:focus-within(自己或任意后代获得焦点时命中自己); - 表单:
:checked、:disabled、:enabled、:required、:optional、:read-only、:placeholder-shown(输入框为空、正显示占位符)、:valid/:invalid; - 链接与导航:
:link、:visited(出于隐私限制,只能改极少数属性)、:target(id 等于当前 URL 井号片段的元素——纯 CSS 的「跳转到哪就高亮哪」); - 顺序有讲究:
:link → :visited → :focus → :hover → :active(LVFHA),写反了后面的会被前面同特异性的规则挡住。
结构伪类与 nth 公式
:first-child/:last-child/:only-child,以及按类型计数的:first-of-type/:last-of-type/:only-of-type;:nth-child(An+B):n从 0 往上取整数。2n(=even)偶数位、2n+1(=odd)奇数位、3n+1每三个的第一个、n+4第 4 个及之后、-n+3前三个;:nth-last-child()从后往前数,:nth-of-type()/:nth-last-of-type()在同标签名的兄弟里数;:nth-child(An+B of S)是较新的扩展:先把兄弟里匹配 S 的筛出来,再在这批里编号。li:nth-child(2n of .visible)是「可见项里的第 2、4、6 个」,和li:nth-child(2n).visible(先在全部兄弟里取偶数位、再要求它带 .visible)是完全不同的两件事——做筛选后的斑马纹只有前者做得对。这个语法较新,用前查一下目标浏览器;:empty(没有子元素)、:root(文档根元素,即<html>,写全局变量的惯用落点)。
:has():CSS 终于有了父选择器
A:has(B)选中的是 A——条件是 A 内部能找到 B。这是 CSS 历史上缺了二十年的能力:根据后代(或后续兄弟)的状态,给祖先上样式;- 参数是相对选择器,可以带组合器开头:
:has(> img)是「直接子里有 img」,:has(img)是「任意后代有 img」,:has(+ p)是「后面紧跟着一个 p」; - 典型场景:
.card:has(img)有配图的卡片换布局、form:has(:invalid)表单有错时禁用提交按钮的样式、label:has(:checked)选中项加粗、body:has(dialog[open])弹窗打开时锁住页面滚动——最后这个尤其漂亮,整页状态不用再靠 JS 往 body 上加类。
/* 状态:键盘导航时出现;鼠标点按钮/链接一般不出现,点文本框仍出现 */
a:hover { color: blue; }
button:focus-visible { outline: 2px solid; outline-offset: 2px; }
.field:focus-within { border-color: royalblue; } /* 后代聚焦 → 自己高亮 */
/* nth 公式:n 从 0 往上取 */
li:nth-child(2n) { } /* 偶数位,= even */
li:nth-child(-n+3) { } /* 前三个 */
li:nth-last-child(2) { } /* 倒数第二个 */
/* of 语法:先筛选、再编号 —— 和下面一行含义完全不同 */
li:nth-child(2n of .visible) { } /* 可见项里的第 2/4/6 个 */
li:nth-child(2n).visible { } /* 全部兄弟里的偶数位 且 带 .visible */
/* :has() —— 参数是相对选择器 */
.card:has(> img) { grid-template-columns: 120px 1fr; }
form:has(:invalid) { --submit-enabled: 0; }
label:has(input:checked) { font-weight: 600; }
body:has(dialog[open]) { overflow: hidden; } /* 不用 JS 加类 */
/* :target —— 纯 CSS 的「跳到哪高亮哪」 */
:target { scroll-margin-block-start: 5rem; background: #ffe; }:nth-child 数的是「所有兄弟元素」,不是「同类型的兄弟」。这是本页最高频的坑。p:nth-child(2) 的读法是「一个 p,并且它是父元素的第 2 个孩子」——如果第 2 个孩子是 <h2>,这条规则就什么都匹配不到,而不是退而求其次去选第 2 个 p。要「第 2 个 p」必须写 p:nth-of-type(2)。同理 :first-child 和 :first-of-type:一个容器里 <h2> 后面跟着 <p>,p:first-child 永远选不中任何东西。凡是容器里混着多种标签,一律用 -of-type 系列。第二处:
:has() 的参数是相对选择器,默认是「后代」。.card:has(img) 会被嵌在卡片深处的任意一张图片触发,包括子组件里的;想限定在自己这一层要写 :has(> img)。此外 :has() 不能嵌套 :has(),也不能匹配伪元素,写了整条规则会失效。第三个:
:empty 只看有没有子元素,它跟「视觉上是不是空的」无关——一个只含 <br> 或一张透明图片的元素并不 empty。(该伪类对纯空白文本的判定在规范演进中放宽过,老资料和新实现可能对不上,不要依赖这个边界写业务逻辑。):focus-visible 而不是 :focus:浏览器会按输入方式判断该不该显示轮廓,键盘用户看得见、鼠标用户不会被一圈框硌到——这正是过去大家用 outline: none 硬删轮廓的原因,而删掉不补是明确的无障碍事故。永远不要只写 outline: none,要么保留默认轮廓,要么用 :focus-visible 提供一个对比度足够的替代。另外
:focus-within 被严重低估:给整个输入框组(label + input + 提示文字)加高亮边框,一行就够,不需要 JS 监听 focus/blur。表单校验样式则优先用 :user-invalid 而非 :invalid——后者在用户还没开始输入时就已经命中了空的必填项,一进页面满屏飘红。伪类选中「已经存在的元素」,伪元素则是凭空造出一个 HTML 里没有的盒子,或者选中元素内部本来无法单独选中的一部分(首行、列表符号、选中的文字)。约定上伪元素用双冒号,以便和伪类区分。
::before / ::after 的三条铁律
- 必须有
content,否则不生成任何盒子。纯装饰就写content: ""——这是最常见的「我写了::after但什么都没出现」的原因; - 它默认是行内盒(
display: inline),所以直接设width/height不会生效。要当装饰块用,先改成block/inline-block,或者让父元素是 flex/grid 容器(此时它会成为一个 flex/grid 项,自动块化); - 它插在元素「内容」的最前 / 最后,是元素的子节点而不是兄弟。所以
.x::before的内容在.x内部、受.x的overflow裁剪,也会被.x的 padding 推开。
由第 3 条直接推出一个高频坑:替换元素上的 ::before / ::after 不会渲染。<img>、<input>、<br>、<iframe>、<video> 这类元素的内容整个被外部资源替换掉了,根本不存在「内容之前」这个位置可以插。
其它常用伪元素
| 伪元素 | 选中的是 | 备注 |
|---|---|---|
::first-line | 首行文字 | 随容器宽度变化,只接受部分属性 |
::first-letter | 首字母 | 做首字下沉 |
::marker | 列表项的项目符号 / 编号 | 只接受有限的属性集(color、font-*、content 等) |
::selection | 用户拖选中的文字 | 只接受 color、background-color、text-shadow 等少数几个 |
::placeholder | 输入框的占位符文字 | 占位符不能代替 <label> |
::backdrop | <dialog> 模态框 / 全屏元素背后的那一层 | 做遮罩不用再自己加一个 div |
::file-selector-button | <input type="file"> 的按钮 | 终于能改样式了 |
别把信息只放进 content
- 伪元素的内容不在 DOM 里,选不中、复制不到,各家屏幕阅读器对它的处理也不一致;
- 所以「必填」「新」这类有含义的标记不能只靠
content: " *"——HTML 里要有真正的<abbr title="必填">或aria-label,伪元素只负责视觉; - 纯装饰的伪元素则相反:应当明确声明它没有可访问名,较新的写法是在 content 里用斜杠给出替代文本并留空(
content: "★" / ""),使用前查一下兼容性。
/* 铁律 1:必须有 content,纯装饰写 "" */
.divider::after {
content: "";
display: block; /* 铁律 2:默认是 inline,设不了宽高 */
height: 1px;
background: currentColor;
}
/* 铁律 3:它是元素的子节点,插在内容首尾 */
.tag::before { content: "#"; color: #999; }
.required::after { content: " *"; color: crimson; }
/* ↑ 视觉标记而已,HTML 上仍要有 required / aria-label */
/* ❌ 替换元素没有伪元素 —— 这条永远不生效 */
img::before { content: "📷"; }
/* ✅ 想给图片加角标:包一层,或用父元素的 ::after */
.thumb { position: relative; }
.thumb::after { content: "📷"; position: absolute; inset-block-start: 4px; }
/* content 还能读属性和计数器 */
a[href^="http"]::after { content: " (" attr(href) ")"; }
/* 其它伪元素 */
li::marker { color: royalblue; } /* 只吃有限的属性集 */
::selection { background: #534ab7; color: white; }
::placeholder { color: #aaa; }
dialog::backdrop { background: rgb(0 0 0 / .5); }<img> / <input> 写 ::before 不会渲染出来——它们是替换元素,内容由外部资源整体替换,没有可供插入的位置。注意伪元素其实被生成了(在 DevTools 里读 content 的计算值仍读得到),只是不参与渲染,所以别按「它不存在」去排查。给图片加角标要包一层容器、用容器的 ::after;给输入框加图标要么用 background-image,要么把图标做成兄弟元素绝对定位上去。(<input> 的情况在各浏览器里表现还不完全一致,但无论如何都不要依赖它。)第二个高频坑:伪元素设了宽高没反应,因为它默认是
display: inline,而行内盒的 width/height 不适用。写装饰块务必先给 display: block 或 inline-block。第三个:
::selection 和 ::marker 都只接受一个很小的属性白名单,写在里面的 padding、font-size(对 ::selection)之类会被静默忽略——不是你写错了,是规范就不允许。最后一点历史包袱:单冒号的
:before / :after / :first-line / :first-letter 是 CSS2 的老语法,至今仍被支持,但新代码一律写双冒号;而 ::marker 这些新伪元素只有双冒号写法。::before / ::after 的价值在于省掉纯装饰用的空 div——分隔线、角标、引号、图标、气泡的小三角,都不该污染 HTML 结构。两个实用技巧:① 用 currentColor 当装饰色,伪元素会自动跟随文字颜色,暗色模式一并解决;② content 能拼 attr(),a[href^="http"]::after { content: " (" attr(href) ")" } 打印样式表里特别好用(把链接地址印出来)。还有一条:
::marker 能改的属性有限(改不了 background,也做不了复杂布局),需要完全自定义的列表符号仍然是 list-style: none + ::before 那套老办法。这三个「函数式伪类」解决同一类问题:把重复的选择器列表收拢成一条,并顺手给你一个控制特异性的开关。上一章的层叠卡讲的是它们各自算多少分,这一卡讲怎么用它们把选择器写短、写稳。
收拢重复::is() 与笛卡尔展开
- 最直接的用途是消灭前缀重复:
header h1, header h2, header h3 {…}写成header :is(h1, h2, h3) {…}; - 它可以出现在选择器的任意位置,包括中段,展开时是笛卡尔积:
:is(.a, .b) :is(.c, .d)等价于四条选择器.a .c, .a .d, .b .c, .b .d——两个三元的:is()就能替掉九条规则; - 但它不能包含伪元素:
:is(p::before)是无效的。
特异性开关:三者的计分方式
| 写法 | 特异性取自 | 用来干什么 |
|---|---|---|
:is(A, B) | 参数里最高的那个 | 收拢列表,保持原有权重 |
:where(A, B) | 恒为 0 | 写默认样式 / reset,让别人好覆盖 |
:not(A, B) | 参数里最高的那个(:not 自身不计分) | 排除,但注意它会涨权重 |
:where() 的存在意义就是这一格「恒为 0」:它让你可以写出结构复杂、却一个类就能盖掉的规则。现代 reset 和组件库的默认样式几乎都包在 :where() 里,就是为了不和使用者的业务代码抢优先级。
宽容列表 vs 普通列表:一个无效选择器的后果完全不同
- 普通的逗号选择器列表是「全有或全无」:
h1, h2, :bogus { color: red }里只要有一个选择器浏览器解析不了,整条规则被丢弃——h1 和 h2 也一起不生效; :is()/:where()接受的是「宽容选择器列表」:解析不了的分支被单独忽略,其余照常工作。:is(h1, :bogus)仍然命中 h1;- 这个差别在渐进增强时很关键:想用一个新选择器、又不想它在老浏览器上拖垮整条规则,把它包进
:is()或:where()即可,不必再拆成两条规则或写@supports; - 代价:既然它宽容,那么拼写错误也不会报错,只会静默不匹配。
:is(.buton)和:is(:bogus)在浏览器看来没区别,调试时要留意; :not()不是宽容列表——它接受的是普通的复杂选择器列表,里面出现一个无效选择器会让整条规则失效。别把「宽容」这个性质想当然地套到它头上。
/* 收拢重复的前缀 */
header :is(h1, h2, h3) { margin-block: 0; }
/* = header h1, header h2, header h3 */
/* 出现在中段时是笛卡尔展开 —— 一条顶九条 */
:is(.a, .b, .c) :is(h2, h3, p) { }
/* :where() 恒为 0 —— 写默认样式的正确姿势 */
:where(.prose) :where(h2, h3) { line-height: 1.25; } /* (0,0,0) */
.prose .lead { line-height: 1.6; } /* (0,2,0) 轻松覆盖 */
/* ⚠️ :is()/:not() 会把参数里最高的一档传染出来 */
:is(#app, .page) .btn { } /* (1,1,0) —— 按 #app 算 */
:where(#app, .page) .btn { } /* (0,1,0) —— 多数时候你要的是这个 */
/* :not() 接受列表;注意这两写法匹配相同、特异性不同 */
li:not(.a):not(.b) { } /* (0,2,1):两个 :not 各计一个类 */
li:not(.a, .b) { } /* (0,1,1):只取最高的一个 */
/* 宽容 vs 不宽容 */
h1, h2, :bogus { color: red; } /* ❌ 整条丢弃,h1/h2 也没样式 */
:is(h1, h2, :bogus) { color: red; } /* ✅ 忽略无效分支,h1/h2 正常 */
li:not(.a, :bogus) { color: red; } /* ❌ :not() 不宽容,整条失效 */:is() 图省事,结果特异性悄悄涨了一档」。:is(#app, .page, body) .btn 实际命中时可能靠的是 body,但特异性一律按参数里最高的 #app 算成 (1,1,0)——之后你在业务里写 .btn.btn--danger((0,2,0))想覆盖它,会莫名其妙失败,而 DevTools 里看两条规则「长得差不多」,极难第一眼看出问题。规则很简单:参数里但凡混进 ID,就换成 :where();混进的都是类且权重一致时,:is() 才是安全的。顺带一处:
:not() 不降权,反而加权。很多人以为「排除」是做减法,实际上 li:not(.done) 是 (0,1,1) 而不是 (0,0,1)。而且 :not(.a):not(.b) 和 :not(.a, .b) 虽然匹配结果完全相同,特异性却分别是「两个类」和「一个类」——需要压低权重时写成后者。第三个:别把「宽容」当成
:not() 的性质。:not() 里塞一个当前浏览器不认识的选择器,整条规则会被丢弃,和普通逗号列表一样。要在 :not() 里用新语法,先包一层 :not(:is(…))。:where(),写「业务样式」就用普通选择器。reset、排版基线、组件库的出厂样式全部归零特异性,使用者一个类就能改——这一条约定能消灭掉项目里绝大部分 !important。② 需要「多选一」的祖先条件时,几乎总该用 :where() 而不是 :is(),因为你要的是「命中」而不是「涨权重」。③ 想用较新的选择器又要兼容老浏览器,包一层 :is() 当保险——老浏览器忽略它、新浏览器正常匹配,比拆规则省事。顺带一提,
:has() 的特异性规则和 :is() 一致(取参数最高),所以 :has(#x) 也会把权重顶到 ID 档,同样可以用 :has(:where(#x)) 把它按下去。盒模型与定位
每个元素都是一个盒子。这一章把「盒子有多大、怎么排、装不下怎么办、放在哪、谁盖住谁」五件事讲透:盒模型与外边距合并、display 的双值语法、overflow 的副作用、五种 position 各自的包含块,以及 z-index 背后的层叠上下文。这里的坑密度是整页最高的——绝大多数「样式明明没问题却对不上」的问题都出在这五张卡里。
页面上每个元素都是一个由内到外的四层盒子:content → padding → border → margin。这一卡管两件事:width 到底算到哪一层(box-sizing),以及外边距为什么经常「少了一半」或者「跑到父元素外面去了」(外边距合并)。后者是新手最大的困惑来源,也是最少被系统讲清楚的一条规则。
box-sizing:width 算到哪一层
content-box(默认) | border-box | |
|---|---|---|
width: 200px 指的是 | 内容区宽 200px | 含 padding 和 border 共 200px |
加 padding: 20px; border: 2px 后 | 实际占宽 244px | 实际占宽仍是 200px |
| 内容区变成 | 仍是 200px | 被挤到 156px |
| 心智负担 | 每次都要心算加法 | 所见即所得 |
这就是为什么 *, *::before, *::after { box-sizing: border-box } 几乎是每个项目样式表的第一行:布局时你关心的永远是「这个盒子在页面上占多宽」,而不是「它的内容区有多宽」。注意 margin 在两种模式下都不算进 width——它是盒子外面的空隙,不属于盒子本身。
外边距合并:三种场景
- 相邻兄弟:上一个元素的
margin-bottom和下一个的margin-top会合并成一个,取两者中较大的那个——不是相加。20px + 40px 得到的是 40px 的间距,不是 60px; - 父子的首尾:父元素和它第一个子元素的
margin-top会合并(margin-bottom与最后一个子元素同理),合并后的外边距跑到父元素外面去。表现就是「我给子元素加了margin-top: 40px,子元素纹丝不动,整个父盒子往下掉了 40px」; - 空块自身:一个既没有内容、也没有 padding / border / height 的块级元素,它自己的上下 margin 会互相合并成一个。
三条重要限定:只发生在块级、常规流、块轴(通常是垂直)方向上。水平方向的 margin 永不合并;浮动元素、绝对定位元素、以及 flex / grid 的子项都完全不参与合并。
还有一个反直觉的细节:负 margin 参与时结果是「最大的正值 + 最小的负值」相加,而不是取绝对值最大的那个。
什么能阻断合并
- 父子之间隔一层 padding 或 border(哪怕只有 1px)——最土但最直观的办法;
- 父元素
overflow设成visible和clip以外的值(hidden/auto/scroll)——历史上的常用偏方,但带来裁剪和滚动的副作用; - 父元素
display: flow-root——专为此设计,没有任何副作用,今天的首选答案; - 父元素变成 flex 或 grid 容器——子项直接不参与合并,这也是现代布局里合并问题「自己消失了」的原因;
- 元素浮动或绝对定位。
它们的共同本质是建立一个新的块格式化上下文(BFC)——一个独立的排版环境,内部的 margin 不再和外面的互相影响。
两个数字把这一卡的两件事都钉住
① box-sizing(Chrome 里读 getBoundingClientRect().width)
width:200px; padding:20px; border:5px solid
content-box → 实际占 250px (200 + 20×2 + 5×2)
border-box → 实际占 200px (padding 与 border 从 200 里挤)
② 外边距合并(子元素 margin-bottom:30px 与 margin-top:20px 相邻)
普通流父容器 → 高 78px 合并成 max(30,20) = 30
flex 列容器 → 高 98px 不合并,30 + 20 = 50 全额生效
差的正好 20px = 被合并掉的那一份- 第二组数字顺手证明了上面那份规避清单里最实用的一条:父容器改成 flex 或 grid,合并立刻消失。这也解释了为什么现代布局里很多人「从没遇到过外边距合并」——他们的容器早就是 flex 了。
- 反过来也提醒:从旧的普通流布局迁到 flex 时,间距会突然变大(原本合并的 margin 现在全额生效)。这类「换了个 display 就整体走形」的现象,根因往往在这里而不在你改的那条规则上。
/* 现代项目的标准起手式 —— 别漏了两个伪元素 */
*, *::before, *::after { box-sizing: border-box; }
.box {
width: 200px;
padding: 20px;
border: 2px solid;
/* border-box:实际占宽仍是 200px(content-box 下会是 244px) */
margin: 0 auto; /* margin 永远不算进 width */
}
/* 场景 1:相邻兄弟 —— 取较大值,不相加 */
.a { margin-bottom: 20px; }
.b { margin-top: 40px; } /* 实际间距 40px,不是 60px */
/* 场景 2:父子首尾 —— margin 跑到父元素外面 */
// <div class="parent"><p class="child">…</p></div>
.child { margin-top: 40px; } /* ⚠️ 结果是 .parent 整体下移 */
/* 阻断合并:三选一,首选 flow-root(无副作用) */
.parent { display: flow-root; } /* ✅ 专为此设计 */
// .parent { padding-top: 1px; } 土办法,会改变尺寸
// .parent { overflow: hidden; } 有裁剪 / 滚动副作用
/* flex / grid 子项完全不参与合并 —— 用 gap 排间距最省心 */
.stack { display: flex; flex-direction: column; gap: 1rem; }margin-top: 40px,元素没动,反倒整个父盒子往下掉了。」——这就是父子外边距合并。子元素的上外边距和父元素的上外边距合成了一个,跑到父元素外侧去了。解法是给父元素建一个 BFC:display: flow-root(首选,零副作用)、或者补一点 padding-top/border-top、或者 overflow: hidden(注意它会裁剪溢出内容,还会顺手影响 position: sticky,见后面的卡)。「两段之间怎么只有 40px,不是 20+40=60px?」——这是相邻兄弟合并,规则是取较大值而非相加。想要精确间距,要么只给一侧写 margin(团队约定「只写
margin-bottom」就能彻底回避),要么改用 gap。还有一个更隐蔽的:一个高度为 0、没有 padding/border 的空
<div> 会「消失」,它自己的上下 margin 合并成一个,还可能继续和前后兄弟的 margin 合并——用空 div 撑间距永远撑不出你算的那个数。border-box 一定要带上 *::before, *::after——伪元素不会被 * 选中,漏掉它们会让装饰块的尺寸行为和其它元素不一致,这种不一致极难定位。② 排间距优先用父容器的 gap,而不是给子元素挨个加 margin。gap 不参与外边距合并、不会溢出到容器外、也没有「最后一个要清零」的问题,一行就把整组间距定死。这正是现代布局宁可为了排间距而建一个 flex 容器的原因——合并规则从此与你无关。真正躲不开合并的场合只剩富文本正文(你无法控制 CMS 吐出来的结构),那里用 .prose > * + * 的猫头鹰写法或 flow-root。display 其实同时回答了两个问题:这个盒子怎么参与父级的排版(外部显示类型),以及它内部的子元素怎么排(内部显示类型)。看懂这一层,inline-block、flow-root 这些看似零散的关键词就串成了一个体系。
双值语法:把两个问题写明白
- 外部显示类型只有两种主要取值:
block(独占一行)和inline(随文字流排); - 内部显示类型决定子元素的排布方式:
flow(常规流)、flow-root(常规流,且建立独立 BFC)、flex、grid、table; - 于是
display: inline-block的真身是display: inline flow-root——「外部按行内摆放,内部是一个独立的块格式化上下文」。这个名字第一次说清了它为什么既能随文字排、又能设宽高、还能包住内部的浮动; - 同理
flex=block flex,inline-flex=inline flex。老的单值写法全部保留、可以一直用,双值语法的价值主要在于帮你建立心智模型(它本身较新,写进生产代码前查一下兼容性)。
常用取值对照
| 单值写法 | 双值等价 | 外部 / 内部 | 说明 |
|---|---|---|---|
block | block flow | 块级 / 常规流 | 独占一行,可设宽高 |
inline | inline flow | 行内 / 常规流 | 宽高不适用 |
inline-block | inline flow-root | 行内 / 独立 BFC | 随文字排但可设宽高 |
flow-root | block flow-root | 块级 / 独立 BFC | 专治外边距合并与包浮动 |
flex | block flex | 块级 / flex | 见 08 章 |
grid | block grid | 块级 / grid | 同上 |
list-item | — | 块级 + 额外生成 ::marker | 让任意元素长出项目符号 |
contents | — | 盒子消失,子元素上提 | 摊平多余的包裹层 |
none | — | 不生成盒子 | 不占位、不可聚焦 |
行内元素的尺寸规则(高频坑)
width/height对display: inline的元素不适用,写了完全没反应——给<span>或<a>设宽高设不动,原因就是这个;- 左右的 padding / border / margin 正常生效;
- 上下的 padding 和 border 会画出来,但不会撑开行高——它们会直接盖住上下两行的文字;上下 margin 则彻底无效;
- 解法:改成
inline-block,或者让父元素变成 flex / grid 容器——flex / grid 会对子项做块化,display: inline的子项会自动变成block,宽高随之生效。
/* 双值语法:外部显示类型 + 内部显示类型 */
.a { display: block flow; } /* = block */
.b { display: inline flow-root; } /* = inline-block 的真身 */
.c { display: block flex; } /* = flex */
/* ⚠️ 行内元素设宽高毫无反应 */
span { width: 100px; } /* ❌ 不适用 */
span.badge { display: inline-block; width: 100px; } /* ✅ */
.flex-parent span { } /* ✅ flex 子项会被块化,宽高直接生效 */
/* flow-root:建 BFC,包住浮动、阻断外边距合并 */
.clearfix { display: flow-root; } /* 取代老式 ::after clear hack */
/* contents:盒子消失,子元素直接成为祖父的子项 */
.wrapper { display: contents; }
/* ⚠️ 它自己的 background / border / padding 也一起消失 */
/* 三种「隐藏」的差别远不止占不占位 */
.gone { display: none; } /* 不占位、不可聚焦、不进无障碍树 */
.invis { visibility: hidden; } /* 占位、不可聚焦 */
.ghost { opacity: 0; } /* ⚠️ 占位、仍可点、仍可 Tab 聚焦 */<span> / <a> 设 width 或 height 没有任何反应——它们默认是 display: inline,这两个属性对行内盒不适用(不是被覆盖了,是根本不生效)。更容易误判的是上下方向:padding-top: 20px 在行内元素上会画出来(背景色确实变高了),但不会把上一行推开,于是背景直接盖住上面那行文字——看起来像「padding 生效了一半」。上下 margin 则是彻底无效。要设尺寸,改 inline-block 或让父级变 flex/grid。另一个容易翻车的地方:三种「隐藏」的可访问性差异远比占不占位重要。
display: none 和 visibility: hidden 的元素都无法被 Tab 聚焦,但 opacity: 0 的元素仍然可以点击、仍然在 Tab 顺序里——做淡出动画时忘了这点,就会得到一个「看不见却挡住点击」的透明遮罩,用户点不到底下的按钮却完全不知道发生了什么。淡出结束后要补 visibility: hidden 或 pointer-events: none。第三个:
display: none 的元素没有布局,因此它上面的 transition 不会触发——「从 display: none 直接过渡到显示」这个需求在传统 CSS 里做不到,需要配合 @starting-style / transition-behavior: allow-discrete 这类较新特性,或者退回 JS 分两帧处理,详见 10 章。display: flow-root 加进你的常用工具箱。它就是「建一个干净的 BFC」这一件事的专用关键词,能同时解决三个经典问题:包住内部的浮动(取代 clearfix hack)、阻断父子外边距合并、让元素不被外部浮动侵入。以前大家为了这个副作用去写 overflow: hidden,代价是内容被裁剪、还会顺手把 position: sticky 的祖先滚动容器搅乱;flow-root 没有任何副作用。另一条:
display: contents 是 Grid 布局里摊平多余包裹层的利器——外层 wrapper 是 React 组件结构需要、但不该参与网格排布时,给它 display: contents,子元素就能直接成为网格项。注意它自己的背景、边框、padding 会一并消失(因为盒子没了),而且它对无障碍树的影响历史上有过实现问题,别用在按钮、链接这类交互元素上。内容装不下盒子时怎么办,由 overflow 决定。它看着是个简单属性,实际藏着两个会咬人的机制:它会顺手创建 BFC,以及 overflow-x 和 overflow-y 不能各行其是。
五种取值
| 值 | 裁剪 | 是滚动容器 | 说明 |
|---|---|---|---|
visible | 否 | 否 | 默认值,内容照常画到盒子外面 |
hidden | 是 | 是 | 没有滚动条,但 JS 仍可 scrollTop / scrollIntoView 滚动它 |
scroll | 是 | 是 | 始终保留滚动条槽位(布局不会因内容多少而跳动) |
auto | 是 | 是 | 需要时才出滚动条,日常首选 |
clip | 是 | 否 | 裁死,任何方式都滚不动;可配 overflow-clip-margin |
hidden 和 clip 的差别值得记一下:前者仍然是一个滚动容器,只是把滚动条藏了——这意味着页面里某个元素获得焦点时,浏览器可能把它「滚」进视野,造成内容莫名其妙偏移。clip 则彻底断了这个可能,做纯视觉裁剪时它更安全。overflow-clip-margin 可以把裁剪边界向外扩一段(给焦点轮廓、阴影留出空间),这两个都较新,用前查一下兼容性。
副作用:它会创建 BFC
- 规范规定:
overflow取visible和clip以外的值,就会建立一个新的块格式化上下文; - 后果是三件事同时发生:内部的浮动被包住、父子外边距合并被阻断、外部的浮动侵入不进来;
- 这就是历史上
overflow: hidden被当作「清除浮动」和「修外边距塌陷」偏方的由来——但那是副作用,不是本意。今天只想要 BFC 就直接写display: flow-root,别再借overflow的道,否则你会白白背上裁剪和滚动容器的包袱。
overflow-x 与 overflow-y 不能一个 visible 一个不 visible
- 规范对计算值有一条强制修正:当其中一个是
visible或clip,而另一个既不是visible也不是clip时(clip与visible搭配则不触发修正),visible会被计算成auto,clip会被计算成hidden; - 所以
overflow-x: visible; overflow-y: hidden;实际生效的是overflow-x: auto; overflow-y: hidden;——你会莫名其妙多出一条横向滚动条,而 DevTools 里写的明明是visible; - 原因是滚动容器在几何上无法只在一个方向裁剪、另一个方向任由内容溢出。结论:「只裁一边、另一边照常溢出」这个需求在 CSS 里做不到,遇到时只能换结构(比如把需要溢出的那部分挪到滚动容器外面,用绝对定位对齐过去)。
.scroll-area {
max-height: 300px;
overflow-y: auto; /* 超高才出滚动条 */
overscroll-behavior: contain; /* 滚到底不带着整页一起滚 */
}
/* ⚠️ 这两行的实际效果是 overflow-x: auto —— 会冒出横向滚动条 */
.oops { overflow-x: visible; overflow-y: hidden; }
/* clip:裁死且不是滚动容器;clip-margin 给阴影/焦点环留余地 */
.crop { overflow: clip; overflow-clip-margin: 8px; }
/* 单行省略号 */
.ellipsis {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* ⚠️ 上面这套放进 flex 子项里会失效 —— 必须补 min-width */
.row { display: flex; }
.row .ellipsis { min-width: 0; } /* 纵向排列时是 min-height: 0 */
/* 只想要 BFC 就别借 overflow 的道 */
.bfc { display: flow-root; } /* ✅ 而不是 overflow: hidden */text-overflow: ellipsis 在 flex 子项里不生效——这是本页最费时间的坑之一,而且三行样式看起来完全没错。原因不在 overflow,在于 flex 子项的 min-width 默认是 auto:它拒绝收缩到比自身内容更窄,于是子项永远和文字一样宽、根本没有溢出,overflow: hidden 也就没有裁剪的机会。解法是给那个子项加 min-width: 0(主轴是纵向时改 min-height: 0)。同样的机制也导致「flex 子项撑破容器」这类问题,记住 min-width: 0 这个开关能省很多事。同样常见的:
overflow: hidden 会让祖先变成滚动容器,从而让内部的 position: sticky 彻底失效——因为 sticky 是相对最近的滚动容器工作的,而这个容器本身不滚动,阈值永远触发不了。项目里 sticky 莫名不生效,第一件事就是沿祖先链找 overflow 不是 visible 的元素(详见下一卡)。第三个:
overflow: hidden 不等于「不能滚动」。它仍是滚动容器,element.scrollTop = 100 照样有效,某个后代获得焦点时浏览器也可能把它滚进视野——想要「绝对不动」用 clip。overscroll-behavior: contain——它切断「滚动链」,避免用户在弹窗或侧边栏里滚到底之后,手指继续滑动就把整个页面也带着滚了(移动端体验杀手)。② 用 scrollbar-gutter: stable 消除布局跳动:内容长度在临界点附近变化时,滚动条时有时无会让整块内容左右抖动,这个属性预留出槽位就稳住了(较新,查一下兼容性)。③ 做全屏遮罩锁滚动时,把 overflow: hidden 设在 <html> 上而不是只设在 <body>——视口的滚动行为优先取自 html,只有 html 是 visible 时才轮到 body,直接设根元素结果最确定。更省事的做法是用 <dialog> 的原生模态(它自带滚动锁定)。定位的一切困惑都能归结成一个问题:top / left 这些偏移值,到底是相对谁算的?这个「谁」有个正式名字叫包含块(containing block)。搞清楚每种 position 的包含块是谁,定位就不再玄学了。
五种取值与它们的包含块
| 取值 | 脱离常规流 | 偏移相对于(包含块) | 典型用途 |
|---|---|---|---|
static | 否 | 不适用,偏移属性完全无效 | 默认值 |
relative | 否(原位置仍占着) | 它自己在常规流里的原位置 | 微调;给子元素当定位锚点 |
absolute | 是 | 最近的已定位祖先的 padding 盒;一个都没有则是初始包含块 | 徽标、下拉菜单、浮层 |
fixed | 是 | 视口(打印时是页面) | 悬浮按钮、固定顶栏 |
sticky | 否 | 仍在常规流里,但会在最近的滚动容器内按阈值偏移 | 吸顶标题、表头 |
「已定位祖先」= position 不是 static 的祖先。这就是「父 relative + 子 absolute」这条口诀的全部含义:relative 什么都不改,只是把自己登记成一个包含块。
还有两个细节值得记住:absolute 相对的是祖先的 padding 盒(不是 content 盒,所以 top: 0 会贴在 padding 的外沿);而不写任何偏移值的绝对定位元素会停在「它如果还在常规流里本该在的位置」,只是不再占空间——看起来像「没动,却把下面的元素吸上来了」。
fixed 会被祖先的 transform 拽下来
position: fixed号称相对视口,但有一个重要例外:祖先链上任何一个元素只要设了transform、perspective、filter、backdrop-filter、contain(paint/layout/strict/content),或用will-change声明了这些属性,它就会取代视口成为fixed的包含块;- 表现就是:你的「固定在屏幕上」的元素变成了「固定在这个祖先里」,跟着页面一起滚走了;
- 常见触发源比你想的多:动画库给容器加的
transform: translateZ(0)硬件加速 hack、卡片 hover 时的transform: scale()、以及为了性能随手写的will-change: transform; - 排查方法:在 DevTools 里选中该元素,沿祖先链一路往上看计算样式,找第一个
transform/filter不是none的。根治办法是把浮层渲染到<body>直下——这正是各种 Portal 机制和原生<dialog>存在的理由。
sticky 不生效的两大原因
- 没设阈值。
position: sticky必须至少给top/right/bottom/left中的一个一个非auto的值(吸顶就是top: 0),否则它退化成一个普普通通的relative,一点反应都没有; - 某个祖先建立了滚动容器。sticky 是相对最近的滚动容器工作的。祖先链上任何一个
overflow不是visible的元素(hidden、auto、scroll都算)都会成为那个容器——如果它自己不滚动,阈值就永远触发不了。这是最难查的一种,往往是很上层一个为了别的目的写的overflow: hidden造成的。
还有第三种「看起来没生效」:sticky 的活动范围被它的父元素边界锁死,父元素滚出视野时它就跟着走。如果父元素的高度恰好等于它自己,那它根本没有可以「粘」的行程——表格里给 <th> 加 sticky 常栽在这上面。
/* 经典:父 relative 登记为包含块 + 子 absolute */
.card { position: relative; } /* 什么都不改,只当锚点 */
.badge {
position: absolute;
inset-block-start: 8px; /* 相对 .card 的 padding 盒 */
inset-inline-end: 8px;
}
/* inset 是四个偏移的简写 */
.cover { position: absolute; inset: 0; } /* = top/right/bottom/left: 0 */
/* ⚠️ fixed 的祖先一旦有 transform,它就固定在那个祖先里 */
.animated-wrapper { transform: translateZ(0); } /* 加速 hack */
.animated-wrapper .fab { position: fixed; } /* ❌ 会跟着滚走 */
/* sticky:必须给阈值,否则等于 relative */
.navbar {
position: sticky;
top: 0; /* 少了这行 = 完全没反应 */
}
/* 且祖先链上不能有 overflow: hidden/auto/scroll */
/* 表格吸顶表头 */
thead th { position: sticky; top: 0; background: white; }position: fixed 怎么跟着页面滚?」——祖先链上有 transform / filter / backdrop-filter / perspective / contain / will-change。这条规则不是浏览器 bug,是规范明确要求的:这些属性会让元素成为其后代中固定定位和绝对定位元素的包含块。最隐蔽的是 transform: translateZ(0) 这类为了「开启 GPU 加速」而随手加的声明,它在视觉上完全没有效果,却会静默改变整棵子树的定位基准。「
position: sticky 一点反应都没有。」——按顺序查三件事:① 有没有给 top/bottom/left/right 中的至少一个赋非 auto 值(这是硬性要求,只写 position: sticky 等于白写);② 祖先链上有没有 overflow 不是 visible 的元素(连 overflow-x: hidden 这种为了防横向滚动随手写的都算,因为它会把 y 方向也拽成 auto,见上一卡);③ 父元素是不是高度刚好等于它自己,根本没有可粘的行程。还有一个小陷阱:绝对定位元素找不到已定位祖先时,会一路上溯到「初始包含块」(大致是视口大小的那块画布)并相对文档定位——页面一滚它就跑了。忘记给父级写
position: relative 时,症状常常是「浮层出现在页面左上角」。gap 表达的关系就别 absolute——绝对定位的元素脱离常规流,父容器不会因为它而变高,一旦内容变长就会溢出,响应式适配全靠手写断点。它真正的适用场景只有一类:「盖在别的东西上面」(徽标、浮层、遮罩、装饰)。三条具体建议:①
position: relative 和它对应的 absolute 子元素要写在一起,别隔着几个文件——「谁是包含块」是隐式契约,分开写就没人看得出来了。② 用逻辑属性 inset-block-start / inset-inline-end 代替 top / right,阿拉伯语等 RTL 语言下自动镜像。③ 需要真正相对视口固定的浮层,渲染到 <body> 直下,或者直接用原生 <dialog> —— 它在浏览器的顶层(top layer)渲染,完全不受祖先的 transform 和 overflow 影响,也自带 ::backdrop。z-index: 9999 写上去却还是被盖住——这几乎是每个前端都遇到过的事。原因只有一个:z-index 只在同一个「层叠上下文」内部比较。理解这一个概念,这类问题就再也不用靠加零来碰运气了。
层叠上下文是什么
- 它是一个独立的堆叠沙盒:一旦某个元素创建了层叠上下文,它的全部后代都被打包进去,整包作为一个整体参与外层的排序;
- 推论:包内子元素写多大的
z-index都跳不出这个包。子元素的 999999 只决定它在包内的位置,包整体在外层是第几,由创建这个包的那个祖先说了算; - 文档根元素
<html>永远创建一个根层叠上下文,所有没被别的上下文圈住的元素都在它里面比较。
哪些情况会创建层叠上下文
- 定位相关:
position: relative/absolute且z-index不是auto;position: fixed和position: sticky(无论z-index是什么,总是创建); - flex / grid 子项且
z-index不是auto——注意这条不要求position,静态定位的 flex 子项设了 z-index 也会创建; - 视觉合成类:
opacity小于 1;transform/scale/rotate/translate/perspective不是none;filter/backdrop-filter不是none;mix-blend-mode不是normal;clip-path/mask不是none; - 显式声明:
isolation: isolate;contain: layout/paint/strict/content;will-change指定了上面任意一个属性。
把这份清单和上一卡「让 fixed 退化的属性」对照一下会发现大量重合——transform、filter、will-change、contain 同时干了两件事:成为包含块,以及创建层叠上下文。所以这两类 bug 常常一起出现,排查时可以一起查。
同一个上下文内部的绘制顺序(从下往上)
- 创建该上下文的元素自身的背景与边框;
z-index为负的子层叠上下文;- 常规流中的块级盒;
- 浮动盒;
- 行内内容;
z-index为0或auto的定位元素;z-index为正的子层叠上下文。
两条能立刻用上的推论:定位元素即使不写 z-index,也天然盖在普通块级内容之上(第 6 层 > 第 3 层)——很多时候你根本不需要写 z-index;而 z-index: -1 会沉到背景之下、但仍在创建该上下文的元素的背景之上,这正是用伪元素做背景装饰的常用手法。
/* z-index 只在同一层叠上下文内比较 */
.modal { position: fixed; z-index: 100; }
/* ⚠️ 经典事故现场 */
.card { transform: scale(1); } /* 建了层叠上下文 */
.card .dropdown { z-index: 9999; } /* ❌ 跳不出 .card 这个包 */
/* → 两张卡片按 DOM 顺序整包比较,下拉菜单被隔壁卡片盖住 */
/* 这些也都会静默创建层叠上下文 */
.a { opacity: .99; } /* 被当作「强制合成」的偏方 */
.b { filter: blur(0); }
.c { will-change: transform; }
.d { position: sticky; } /* 不管 z-index 是什么 */
/* ✅ 只想圈住一个子树的堆叠范围,用 isolation */
.widget { isolation: isolate; } /* 零副作用,也不用编 z-index 数字 */
/* 别搞军备竞赛,定义几档语义层级 */
:root {
--z-dropdown: 10;
--z-sticky: 20;
--z-modal: 100;
--z-toast: 1000;
}
.dialog { z-index: var(--z-modal); }
/* z-index: -1 沉到内容之下、但仍在本元素背景之上 */
.deco { position: relative; }
.deco::before { content: ""; position: absolute; inset: 0; z-index: -1; }transform: scale(1.02) 做放大,卡片里的下拉菜单写了 z-index: 9999,却突然被隔壁那张卡片盖住了。因为 transform 给每张卡都创建了层叠上下文,两张卡是整包按 DOM 顺序比较的,包内的 9999 完全无关。解法是提升那个祖先(卡片)的 z-index,而不是继续给子元素加零;或者把浮层用 Portal 移出去。此外还有一件:
z-index 对 position: static 的元素无效(flex / grid 子项是唯一的例外)。「设了 z-index 完全没反应」时,第一件事是看有没有 position。第三个坑:
opacity: 0.99 会静默创建层叠上下文。很多人拿它当「强制 GPU 合成」的性能偏方,视觉上看不出任何差别,却把整棵子树的堆叠关系锁进了一个盒子里——这种 bug 因为「改动看起来完全无害」而极难被联想到。同理还有为了性能随手写的 will-change: transform。第四个:
z-index: -1 的元素会沉到父元素的背景之上、内容之下,但如果父元素自己创建了层叠上下文,它就沉不到父元素背景下面——想让伪元素装饰跑到父背景之下是做不到的,只能靠调整父元素本身的背景层次。isolation: isolate。它是唯一一个只做这一件事的属性——不像 opacity: .99 会带来真实的透明度和合成开销、也不像 transform: translateZ(0) 会顺手把内部的 fixed 定位基准也改掉,更不需要你编一个 z-index 数字出来。典型用法是给每个独立组件的根节点加上它,组件内部的 z-index 从此爱怎么写怎么写,绝不会泄漏出去影响别人。另一条团队约定:项目里禁止裸写
z-index: 9999。定义四五档语义变量(--z-dropdown: 10; --z-sticky: 20; --z-modal: 100; --z-toast: 1000)并在代码评审时守住——数字之间留出间隔,方便日后插层。z-index 的军备竞赛一旦开始就再也停不下来,而它反映的往往是「没人知道全局层级长什么样」这个更深的问题。「下拉菜单跟着按钮走、贴到屏幕边缘时自动翻到上面去」——这件事过去必须用 JS 算坐标(Floating UI 之类的库就是干这个的)。现在它是纯 CSS:一个元素声明自己是锚点,另一个元素声明贴着谁。而且两者不需要有任何父子关系。
三行就能钉住
- 锚点方:
anchor-name: --menu-btn(自定义标识,和变量一样的写法); - 浮层方:必须是
absolute或fixed,写position-anchor: --menu-btn,然后用anchor()取锚点的边——top: anchor(bottom)就是「我的上边贴它的下边」; - (Chrome 150):锚点按钮位于
x200 y116、高 30px,浮层写top: anchor(bottom); left: anchor(left)后落在x200 y146——正好是 116 + 30; anchor-size(width)还能取锚点的尺寸:width: anchor-size(width)让浮层与按钮等宽(100px 对 100px),这是「下拉列表和输入框一样宽」的标准解法。
position-area:不用算边,直接说方位
- 把锚点周围看成一个 3×3 的九宫格,
position-area: top center就是「正上方居中」,inline-end center是「行内方向的末端、垂直居中」(用逻辑方位,自动适配 RTL); - 同一个锚点(x200 y116 w100 h30)配 120×40 的浮层:
top center→ x190 y76(水平居中、正好贴在上方),inline-end center→ x300 y111(贴右侧、垂直居中); - 它和
anchor()是两条并行的路:要「九个方位之一」用position-area,要精确到某条边或带偏移用anchor(),混着写只会互相覆盖。
碰撞回退:贴边时自动翻面
position-try-fallbacks: flip-block给出内置回退(还有flip-inline、flip-start),也可以用@position-try --up { … }自定义一整套位置;- 视口高 480px、锚点在
y430(高 30),浮层原本落在 y460——高 40px,底边 500 已经出屏;加上flip-block后自动改到 y390(翻到锚点上方)。自定义的@position-try --up { top: auto; bottom: anchor(top) }结果完全相同; position-visibility: anchors-visible让锚点滚出可视区时浮层自己隐藏——不写它,浮层会孤零零地留在屏幕上。
它顺手解决了 Portal 那个老问题
- 本章前面几卡讲过:祖先上有
overflow: hidden/transform/filter时,浮层会被裁掉或者定位错位,z-index调多大都没用,所以组件库一律把浮层「传送」到<body>下; - 锚点定位里浮层本来就可以放在
<body>下、而锚点留在深处——两者靠名字关联,DOM 层级无关。把锚点塞进一个overflow: hidden的 60×60 小盒子里,fixed浮层依然完整显示在 x500 y337,一点没被裁; - 换句话说:过去要「JS 算坐标 + Portal 搬 DOM」两件事,现在只剩「把浮层放对地方」一件。已有的无头组件库仍然有价值(它们卖的是焦点与无障碍,见 17 章),但定位这一层可以交还给 CSS。
/* ① 锚点方:给自己起个名字 */
.menu-btn { anchor-name: --menu-btn; }
/* ② 浮层方:必须定位,然后说贴着谁 */
.menu {
position: fixed;
position-anchor: --menu-btn;
top: anchor(bottom); /* 我的上边 = 它的下边 */
left: anchor(left);
margin-top: .5rem; /* 间距用 margin,别去算偏移 */
min-width: anchor-size(width); /* 至少和按钮一样宽 */
position-try-fallbacks: flip-block; /* 贴底时翻到上方 */
position-visibility: anchors-visible; /* 锚点滚走就隐身 */
}
/* ③ 或者不算边,直接给方位(九宫格 + 逻辑方向) */
.tooltip {
position: absolute;
position-anchor: --menu-btn;
position-area: top center;
}
/* ④ 自定义回退:按顺序试,第一个装得下的赢 */
@position-try --above { top: auto; bottom: anchor(top); margin: 0 0 .5rem; }
@position-try --right { left: anchor(right); top: anchor(top); margin: 0 0 0 .5rem; }
.menu { position-try-fallbacks: --above, --right; }position-anchor 是第一个坑,而且它不报错。只写 top: anchor(bottom); left: anchor(left) 但没指定锚点时,anchor() 无从解析,浮层静默退回普通定位,落在 x0 y16(页面左上角)而不是按钮旁边。症状是「浮层跑到页面角上去了」,很容易误判成层级或坐标算错。另一处:
anchor() 只对 absolute / fixed 生效。在 position: static 的元素上写 top: anchor(bottom),计算值直接是 auto——和写错属性名一样,什么都不会发生。第三个:一个
anchor-name 被多个元素声明时,浮层锚定的是「DOM 里最后一个」,不是最近的那个。列表里每一行都要自己的浮层时,名字必须逐行唯一(拿行 id 拼出来),否则所有浮层会挤到同一处。最后一条不是坑而是提醒:这是新特性,跨浏览器状态请当场查 caniuse / MDN 的兼容表,别照本页的数字(Chrome 150)当结论。降级写法是
@supports (anchor-name: --x) 里放锚点版、外面留一份 position: absolute 的固定方位版。popover 属性是为彼此设计的一对:popover 负责「开合、点外部关闭、进 top layer 所以永远不被遮住」,锚点定位负责「贴在哪」。两个原生特性拼起来,一个不写 JS 的下拉菜单就齐了——<button popovertarget="m"> 加 <div id="m" popover>,再给这两个元素配上 anchor-name / position-anchor。想让它有淡入淡出,再加 10 章那张卡里的 @starting-style 与 transition-behavior: allow-discrete。这四样东西合起来,覆盖了「简单浮层」的全部需求。Flexbox 与 Grid
Flexbox 管一维排列,Grid 管二维布局,二者是现代 CSS 布局的两大支柱。本章不只罗列属性,更要回答三个真问题:这个场景该用哪个、为什么子项撑破了容器、那一堆 justify/align/place 到底谁管谁。前两个问题是选型,第三个是全 CSS 最容易记混的一套命名。
Flexbox 的全部属性都挂在一句话上:先问主轴是哪条。flex-direction 决定主轴方向,justify-* 永远管主轴、align-* 永远管交叉轴——记住这条,就不用死背每个属性作用在哪个方向。
主轴与交叉轴:一个可旋转的坐标系
flex-direction: row(默认)时主轴水平、交叉轴垂直;改成column,两条轴整体旋转 90°,justify-content: center于是从「水平居中」变成「垂直居中」;- 这也是「我明明写了
justify-content: center怎么变成上下居中了」的唯一原因——不是属性坏了,是主轴换了; row-reverse/column-reverse只反转主轴的起止端,此时flex-start指的是右边 / 下边。
四个容器属性各管一段
justify-content:主轴上分配剩余空间——flex-start/center/space-between/space-around/space-evenly;align-items:交叉轴上每个子项在自己那一行里怎么放。默认值是stretch,所以不写任何对齐时,子项会被拉伸到和最高的兄弟一样高——很多人以为是自己写错了高度,其实这是默认行为;flex-wrap:默认nowrap,子项宁可被压扁也不换行;gap:轨道之间的固定间距。
gap 与 justify-content 的关系:先扣 gap,再分剩余
- 浏览器先按
gap撑开各子项之间的固定缝隙,剩下的空间才交给justify-content分配; - 所以
gap是「最小保证间距」,space-between得到的实际间距只会 ≥ gap,不会小于它; - 如果剩余空间是 0(子项已经塞满),
justify-content写什么都看不出区别——这时候要调的是子项的flex,不是容器的对齐。
.container {
display: flex;
flex-direction: row; /* 主轴:水平 */
justify-content: space-between; /* 主轴对齐 */
align-items: center; /* 交叉轴对齐 */
flex-wrap: wrap; /* 放不下换行 */
gap: 1rem; /* 子项间距 */
}
/* 完美居中:一行搞定 */
.center { display: flex; justify-content: center; align-items: center; }align-content(管多行整体在交叉轴上的分布)只有在容器真的变成多行时才生效。默认 flex-wrap: nowrap 下容器永远是单行,此时 align-content 完全无效——你调它半天页面纹丝不动,缺的其实是 flex-wrap: wrap。这和 Grid 不一样:Grid 容器只要有多余空间,align-content 就管用。另外别把 align-content 和 align-items 搞混:前者管「行与行之间」,后者管「子项在自己行内」。position:display: flex; justify-content: center; align-items: center 三行搞定,或者更短的 display: grid; place-items: center(见本章「对齐属性其实是一套统一的」卡)。另外,先写 flex-direction 再写对齐属性是个好习惯——把主轴先钉死,后面每一行都好读。flex 是 flex-grow / flex-shrink / flex-basis 的简写,三个数分别回答:有富余时我抢多少、不够时我让多少、我的起始尺寸是多少。而 Flexbox 最高频的真实坑不在这三个值上,而在一个你从没写过的属性:min-width: auto。
常用简写的完整展开
| 简写 | 等价于 | 含义 |
|---|---|---|
flex: 1 | 1 1 0% | 基础尺寸归零后等分空间——多个 flex: 1 的子项最终等宽,与内容多少无关 |
flex: auto | 1 1 auto | 先按内容撑开,再把剩余空间等分——内容多的最终就更宽 |
flex: none | 0 0 auto | 完全不伸不缩,就按内容尺寸 |
flex: 0 0 240px | —— | 固定 240px 的侧栏,不参与伸缩 |
| (不写) | 0 1 auto | flex 子项的初始值:不抢空间但会被压缩 |
flex: 1 与 flex: auto 是最容易混的一对:想要「几个等宽的列」用 flex: 1,想要「按内容比例分、只是把富余匀一匀」用 flex: auto。
flex-basis 与 width:谁说了算
- 在
row方向上,只要flex-basis不是auto,它就覆盖width——写了flex: 1(basis 为0%)之后再写width: 300px,那个 300px 基本不起作用; flex-basis: auto时才回落去看width(没有width再看内容尺寸);- 注意 basis 作用在主轴上:
flex-direction: column时它对应的是高度而非宽度; max-width/min-width属于约束,永远在最后一步生效,能盖住 basis 算出来的结果。
头号坑:flex 子项的自动最小尺寸
- flex 子项的
min-width初始值是auto,含义是「不许小于我的内容最小宽度」——一个长 URL、一段不换行的文本、一张大图,都能把这个下限顶得很高; - 后果就是那个著名现象:写了
flex: 1却撑破容器 / 出现横向滚动条 /flex-shrink看起来完全失灵。不是 shrink 没生效,是它被最小尺寸挡住了; - 解法:给该子项加
min-width: 0(column 方向则是min-height: 0),或者加overflow: hidden(overflow不是visible时,自动最小尺寸就变成 0); - 配合文本省略号时通常两个一起写:
min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap。
.sidebar { flex: 0 0 240px; } /* 固定宽,不伸不缩 */
.main { flex: 1; } /* = 1 1 0%:占满剩余 */
/* flex: 1 与 flex: auto 的差别 */
.equal { flex: 1; } /* basis 0% → 各项最终等宽 */
.byText { flex: auto; } /* basis auto → 内容多的更宽 */
/* ⚠️ 头号坑:子项默认 min-width: auto,长内容会撑破容器 */
.truncate {
flex: 1;
min-width: 0; /* 解除自动最小尺寸,shrink 才真的生效 */
overflow: hidden; /* 等效解法之一,也是省略号的前提 */
text-overflow: ellipsis;
white-space: nowrap;
}
.item {
align-self: flex-end; /* 单独覆盖交叉轴对齐 */
order: -1; /* 只改视觉顺序,Tab 顺序不变 */
}order 只改视觉顺序,不改 DOM 顺序,因此也不改键盘 Tab 的先后。用 order 把「提交」按钮挪到视觉上的第一位,键盘用户 Tab 过去时仍然是最后才到,屏幕阅读器也按 DOM 顺序念——这是实打实的无障碍缺陷(见 04 章的键盘可达卡)。order 只适合做纯装饰性的重排,顺序有语义时请改 HTML。同理,row-reverse 也只是视觉反转。min-width: 0——这一条能解释掉现实中一大半的 flex 溢出问题。日常只需要记三个写法:等宽列 flex: 1、固定侧栏 flex: 0 0 240px、按内容不伸缩 flex: none,剩下的场合再展开三值。这两者不是「新旧替代」关系,而是两种不同的布局思路。一句判据先摆在这:内容决定布局用 Flex,布局决定内容用 Grid。
逐条对照
| 维度 | Flexbox | Grid |
|---|---|---|
| 维度 | 一维:一次只管一条轴上的排列 | 二维:行与列同时定义 |
| 尺寸由谁决定 | 多由内容决定:先看子项本身多大,再分配富余 | 多由容器决定:先画好轨道,内容进格子 |
| 跨行对齐 | 换行后各行独立,第二行的子项不会和第一行对齐到同一条线 | 所有子项天然落在同一套网格线上,行列都对齐 |
| 换行行为 | flex-wrap: wrap,放不下才折,每行个数随内容变 | 轨道数是先定好的,元素按 grid-auto-flow 自动填格 |
| 典型场景 | 导航条、工具栏、标签组、按钮组、「图标 + 文字」这种小簇 | 页面骨架、卡片网格、表单双列、仪表盘 |
怎么快速判断
- 问自己:我是在排一串东西,还是在划一块版面?排一串 → Flex;划版面 → Grid;
- 再问:我在乎的是「它们挨着」还是「它们对齐到同一条线」?只要挨着 → Flex;要对齐 → Grid;
- 还有个信号:如果你在 Flex 里靠
width: 33.33%加gap手算列宽、还得用calc()减去间距,那多半该换 Grid 的repeat(3, 1fr)了——Grid 的fr天生就是扣掉 gap 之后再分的。
不是二选一:嵌套混用才是常态
- 真实页面通常是 Grid 搭骨架、Flex 填内容:外层 Grid 分出 header / sidebar / main,header 内部再用 Flex 排 logo 和导航;
- Grid 子项自己也可以是
display: flex,反之亦然,没有任何冲突; - 两者共享同一套对齐属性和
gap,从一个切到另一个的迁移成本比想象中低。
/* 骨架用 Grid:二维、由布局决定内容 */
.layout {
display: grid;
grid-template-columns: 240px 1fr;
grid-template-rows: auto 1fr auto;
min-height: 100dvh; /* 移动端用 dvh,见 11 章 */
gap: 1rem;
}
/* 骨架里的一条导航用 Flex:一维、由内容决定 */
.navbar {
display: flex;
align-items: center;
gap: 0.75rem; /* 几个链接都无所谓,加删不用改 CSS */
}
/* 卡片网格:Grid 才能保证行列都对齐、各行等高 */
.cards {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 1rem;
}flex-wrap: wrap 加百分比宽度看起来能排成三列,但最后一行不满时会拉伸或居中,且各行的高度互不相关——想让所有卡片等高又等宽还得再打补丁。这正是 Grid 的主场。反过来的误用是用 Grid 做导航条:元素个数一变就得改 grid-template-columns,而 Flex 天然不关心有几个子项。grid-auto-flow: column 就是一行排列),反过来 Flex 却做不了真正的二维对齐。但对数量不定、宽度随内容变的小元素(标签、按钮组),Flex 的 wrap 依然更自然。display: grid 之后,你的工作是先把轨道画出来,元素再落进格子。理解 Grid 只需要抓住三件事:显式轨道与隐式轨道的分界、fr 到底在分什么、auto-fit 与 auto-fill 差在哪。
显式轨道 vs 隐式轨道
- 你用
grid-template-columns/grid-template-rows亲手写出来的是显式轨道; - 元素比格子多时,浏览器会自动补出隐式轨道来装剩下的元素——它们的尺寸由
grid-auto-rows/grid-auto-columns决定,不写的话就是auto(由内容撑开); - 「我只定义了列,行是自动来的」就是这个机制:只写
grid-template-columns时,所有行都是隐式行,想统一行高就写grid-auto-rows: minmax(120px, auto); grid-auto-flow决定自动放置的方向:默认row(逐行填),改成column就是逐列填,加dense允许回填前面留下的空洞(但 dense 会打乱视觉顺序与 DOM 顺序的对应关系,同样有 Tab 顺序问题)。
fr 的真实含义,以及 Grid 版的同一个坑
fr分的是剩余空间:先扣掉固定尺寸的轨道和所有gap,剩下的按比例切。所以repeat(3, 1fr)是「三等分剩余」,不是「各占 33.33%」——这也是它比百分比好用的原因,不用手动calc()减 gap;- 但
1fr实际隐含minmax(auto, 1fr),那个auto下限的意思和 flex 的min-width: auto一模一样:轨道不许比内容的最小尺寸还窄; - 于是同一个现象重演:一段长文本或一张大图,把某一列顶宽,三等分变得不再等分,甚至整个网格溢出;
- 解法就是把下限写死:
minmax(0, 1fr)。(也可以像 flex 那样给子项加min-width: 0/overflow: hidden。)
auto-fit 与 auto-fill:差别只在「空轨道折不折叠」
- 两者都配合
repeat()和minmax()让列数随容器宽度自动变化; auto-fill:塞满能塞下的轨道数,即使某些轨道里没有元素,这些空轨道仍然占据宽度;auto-fit:同样先算出轨道数,但把空轨道折叠成 0,于是剩余空间被现有元素分掉;- 结果差异:容器很宽而元素只有 2 个时,
auto-fill让这 2 个保持minmax的小尺寸靠左排,auto-fit则让它们拉伸铺满整行; - 怎么选:希望少量元素撑满用
auto-fit,希望它们保持正常卡片宽度、不因为数量少就变成两个巨无霸,用auto-fill。元素数量足够填满时两者表现一致。
.grid {
display: grid;
/* 显式列:三等分剩余空间(先扣掉 gap) */
grid-template-columns: repeat(3, minmax(0, 1fr)); /* 0 下限防溢出 */
/* 隐式行:多出来的行统一最小 120px,内容多可再长 */
grid-auto-rows: minmax(120px, auto);
gap: 1rem 1.5rem; /* 行间距 列间距 */
}
/* ❌ 1fr 隐含 minmax(auto, 1fr):长内容会顶宽某列,等分失效 */
/* grid-template-columns: repeat(3, 1fr); */
/* 响应式自动列:auto-fill 保持卡片宽度,auto-fit 少量元素时铺满 */
.auto-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 1rem;
}
/* 逐列填充,并允许回填空洞(注意会打乱视觉顺序) */
.masonry-ish { grid-auto-flow: column dense; }minmax() 里fr 只能出现在 max 位置,写 minmax(1fr, 2fr) 是无效声明,整条会被丢弃。还有一个隐蔽的:repeat(auto-fill, ...) / repeat(auto-fit, ...) 要求该方向上有确定的尺寸参考,在一个宽度完全由内容决定的容器里,自动重复算不出结果。另外 gap 只作用于轨道之间,不会在网格外缘产生间距——外边距请用 padding。repeat(auto-fill, minmax(220px, 1fr)) 是响应式网格的通用起手式:列宽至少 220px、能放几列放几列、剩余空间均分,一行 CSS 顶掉一串媒体查询。想让元素少时也铺满就换 auto-fit。另外把这行的 1fr 改成 minmax(0, 1fr) 是更安全的默认写法。轨道画好之后,子项要回答「我从哪条线到哪条线」。Grid 给了三种说法:数线号、用 span 数格数、或者干脆给区域起名字——名字这种写法能让整页布局在 CSS 里一眼看出形状。
定位子项的四种写法
- 线号:
grid-column: 1 / 3——从第 1 条线到第 3 条线,也就是占 2 列。注意数的是线不是格,n 列有 n+1 条线; - span:
grid-column: span 2——不关心从哪开始,就占 2 格,配合自动放置最省心;两种可以混:grid-column: 2 / span 3; - 负行号:
-1是最后一条线,-2是倒数第二条。所以grid-column: 1 / -1是「横跨整行」的标准写法,列数怎么改它都对。注意负号只对显式网格的线有意义; - 命名线:在模板里用方括号给线起名
[content-start] 1fr [content-end],子项写grid-column: content-start / content-end,改布局时不用重数数字。
grid-area 的两种用法
- 四值形式的顺序是
row-start / column-start / row-end / column-end——先行后列,起点在前终点在后,容易记反,写成1 / 2 / 3 / 4时务必核对; - 拿不准就别用四值简写,分开写
grid-row: 1 / 3; grid-column: 2 / 4;更不容易错,也更好读; - 单值形式
grid-area: header则是引用grid-template-areas里定义的区域名(不加引号)。
grid-template-areas 的硬性规则
- 每一行是一个字符串,所有行的单元格数必须相同,整体拼出来必须是个矩形——多一个少一个整条声明作废,且不会报错,只是布局默默失效;
- 同名单元格必须连续且构成矩形,L 形或分散的同名区域是无效的;
- 空单元格用
.表示,连写多个点(...)算一个空格子; - 列宽仍然由
grid-template-columns决定,areas只画形状不定尺寸; - 建议把字符串按列对齐排版书写——它就是布局的 ASCII 草图,对齐了才好核对是不是矩形。
/* 线号 / span / 负号 */
.featured { grid-column: 1 / 3; } /* 第1条线→第3条线,占2列 */
.wide { grid-column: span 2; } /* 不管起点,占2格 */
.fullrow { grid-column: 1 / -1; } /* 横跨整行,列数变了也对 */
/* 四值简写顺序:row-start / col-start / row-end / col-end */
.hero { grid-area: 1 / 1 / 3 / 3; } /* 等价于下面两行 */
/* .hero { grid-row: 1 / 3; grid-column: 1 / 3; } ← 更好读 */
/* 命名区域:字符串必须拼成矩形,. 表示空单元 */
.page {
display: grid;
grid-template-areas:
"header header"
"sidebar main"
". footer"; /* 左下角故意留空 */
grid-template-columns: 240px minmax(0, 1fr);
}
.header { grid-area: header; } /* 名字不加引号 */
.sidebar { grid-area: sidebar; }
.main { grid-area: main; }
/* 命名线:改布局不用重数数字 */
.named {
grid-template-columns: [full-start] 1rem [content-start] 1fr [content-end] 1rem [full-end];
}
.prose { grid-column: content-start / content-end; }grid-area: header 里的名字不能加引号,加了就变成无效值。另一个高频困惑:子项的 margin: auto 在 Grid 里是有效的居中手段,但一旦用了它,容器上的 align-items / justify-items 就对这个子项失去作用(auto 边距会先吃掉所有剩余空间)——调对齐调不动时先看看有没有残留的 margin: auto。最后,grid-template-areas 里写了名字但没有任何元素认领,那块区域只是留空,不会报错,容易漏掉。grid-template-areas:改版式时只需要重画那几行字符串,子项的 CSS 一行都不用动;配合媒体查询在窄屏重画成单列,是最易读的响应式骨架写法(见 11 章)。而组件内部的零散跨格用 span 更灵活,不必去数线号。justify-content / align-items / place-self……这些名字看起来是 Flex 和 Grid 各有一套,其实它们同属一份 CSS Box Alignment 规范,是同一套属性。搞清两条命名规则,这十来个属性一次记完。
规则一:前缀决定「哪条轴」
- 在 Grid 里最简单:
justify-*管行内轴(默认书写方向下就是横向 / 列方向),align-*管块轴(纵向 / 行方向)——和flex-direction无关,因为 Grid 没有主轴概念; - 在 Flex 里则跟着主轴走:
justify-*管主轴,align-*管交叉轴。所以flex-direction: column时,justify-content管的是纵向——这就是上面「主轴心智模型」那张卡说的事。
规则二:后缀决定「对齐谁」
| 后缀 | 写在哪 | 对齐的对象 |
|---|---|---|
-content | 容器 | 整体在容器里怎么分布:Flex 里是子项 / 行的分布,Grid 里是整套轨道相对容器的分布(轨道总宽小于容器时才看得出来) |
-items | 容器 | 每个子项在自己格子里怎么放(给所有子项设默认值) |
-self | 子项 | 这一个子项覆盖容器给的默认值 |
合起来六个:justify-content / justify-items / justify-self / align-content / align-items / align-self。place-* 是同轴向的简写,顺序永远是 align 在前、justify 在后:place-items: center start = align-items: center; justify-items: start;只写一个值时两轴同值,所以 place-items: center 就是万能居中。
Flex 容器上的两个例外
justify-items和justify-self在 Flexbox 里不生效——因为 flex 子项在主轴上是一起参与空间分配的,没有「各自的格子」这个概念,主轴位置只能由容器的justify-content和子项的flex决定。想让某个 flex 子项单独推到主轴末端,用margin-left: auto(自动边距会吃掉剩余空间),这是 Flex 里的标准技巧;- 交叉轴上没有这个限制:
align-items和align-self在 Flex 里都好用; align-content在 Flex 里需要多行才生效(见容器属性卡),在 Grid 里则一直有效。
/* 最短的居中:一行 */
.center { display: grid; place-items: center; }
/* Grid:三个后缀各管一层 */
.grid {
display: grid;
grid-template-columns: repeat(3, 120px);
justify-content: center; /* 整套轨道在容器里居中 */
justify-items: start; /* 每个子项在自己格子里靠左 */
align-items: center; /* 每个子项在自己格子里垂直居中 */
}
.grid > .special { justify-self: end; } /* 只这一个靠右 */
/* place-* 简写:align 在前,justify 在后 */
.cell { place-self: center end; } /* = align-self:center; justify-self:end */
/* Flex:justify-items / justify-self 无效,用 auto 边距代替 */
.toolbar { display: flex; align-items: center; gap: .5rem; }
.toolbar .logout { margin-left: auto; } /* 推到主轴末端 */justify-items: center 是彻底无效的,浏览器不会报错、也不会有任何变化——很多人由此得出「对齐属性玄学」的结论,其实只是选错了轴向的属性。同理,在 Grid 里写 align-content: center 却没反应,通常是因为轨道已经填满容器、根本没有剩余空间可分(比如行高是 1fr),这时该调的是 align-items。display: grid; place-items: center 一行到底,是全 CSS 最短的居中写法。网格里放一排卡片,每张卡自己也是个网格——结果两张卡的「标题、正文、页脚」三行各自为政,标题一折行,那张卡的页脚就往下掉。这是嵌套网格的固有问题:内层网格不知道外层的轨道在哪。subgrid 就是让它知道。
问题长什么样
- 两张卡并排,卡内
grid-template-rows: auto auto auto。左卡标题一行、右卡标题折成两行; - (Chrome 150,行高 24px):两张卡的「页脚」纵坐标差 24.0px——正好一个行高。视觉上就是两条底边对不齐;
- 过去的办法:给标题写死高度(内容一多就截断)、或者用 JS 量完再统一(改一次窗口宽度就得重算)。
subgrid 怎么写
- 外层把行也定义出来:
grid-template-rows: auto auto auto(原来只定义列就行,现在行也要); - 卡片本身跨满这三行:
grid-row: span 3,然后grid-template-rows: subgrid——意思是「我的行就用父网格的那三行,不要自己算」; - 同一份内容改成 subgrid 后,两张卡页脚的纵坐标差 0.0px,逐字节相同。这一条不受内容长度影响,因为三行的高度是外层网格统一算的;
- 列也能 subgrid:
grid-template-columns: subgrid。典型用途是表单——「标签列 + 输入列」的宽度由最外层统一决定,每一行的标签自动等宽,不用写width: 8rem这种猜出来的值。
两条容易忽略的规则
subgrid继承的是轨道,不是间距:gap默认继承外层,但子网格可以自己覆盖;- 子网格必须显式跨越足够多的轨道。只写
grid-template-rows: subgrid而不写grid-row: span 3,它只跨 1 行——三行内容全挤进一行里,看起来像 subgrid「没生效」; - 轨道数量不匹配时不报错,多出来的内容排进隐式轨道,对齐效果部分消失——这类「一半对齐一半不对齐」的现象基本都是跨越数写错了。
顺便说清 masonry(瀑布流):还不能用
- 「高度不等的卡片自动错落堆叠」是长期的需求,规范这几年在
grid-template-rows: masonry与item-flow: row masonry两套语法之间反复; - Chrome 150:
grid-template-rows: masonry、item-flow、item-pack、masonry-slack、display: masonry全部返回不支持; - 所以今天要瀑布流,仍然是三条老路:
columns多列(阅读顺序会变成竖着读,不适合卡片列表)、JS 库算绝对定位、或者接受等高卡片——最后这条常常是最好的产品决定。
<!-- 卡片列表:三行对齐 -->
<!-- .cards > .card > (h3 + p + footer) -->
.cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr));
grid-template-rows: auto 1fr auto; /* 行也要定义出来 */
gap: 1rem;
}
.card {
grid-row: span 3; /* 必须跨满,否则只占 1 行 */
display: grid;
grid-template-rows: subgrid; /* 用父网格的那三行 */
}
/* 表单:标签列宽度由外层统一决定 */
.form {
display: grid;
grid-template-columns: auto 1fr;
gap: .75rem 1rem;
}
.form .row {
grid-column: span 2;
display: grid;
grid-template-columns: subgrid; /* 每行的标签自动等宽 */
}grid-row: span N 的症状不是「没对齐」,而是「卡片被压扁」——这一点和直觉相反,才看清。同一份三行内容,正确写法下外层网格总高 116px;只写 grid-template-rows: subgrid 而漏掉 grid-row: span 3 时,子网格只拿到一条轨道(计算值是 subgrid [] [],两条网格线=一条轨道),卡片高度掉到 48px、外层总高只剩 68px,内容溢出到卡片外面。两张卡的底边此时反而还是对齐的(差 0.0px),所以「对齐正常但内容重叠」才是该联想到这一条的信号。还有一处:子项个数多于跨越的轨道数时,多出来的排进隐式轨道(计算值仍是
subgrid [] [] [] []),版式会在那一张卡上悄悄走形——卡片模板必须结构一致,这是用 subgrid 的前提。第三个:间距默认继承外层。外层
row-gap: 30px 时子网格内部间距就是 30px,写上自己的 row-gap: 4px 才变成 4px。想让卡内紧凑、卡间宽松,必须显式覆盖。第四个:别把 subgrid 当瀑布流用。它做的是「对齐到同一套轨道」,瀑布流要的恰恰是「各列互不对齐」——两者方向相反。
subgrid 最大的价值不是省代码,而是让「对齐」这件事回到 CSS 的声明层:以前跨组件对齐只能靠约定一个魔法数字(--label-width: 8rem),每次内容变化都要有人回来改;现在轨道宽高由内容和外层网格一起算出来,没有任何数字需要维护。判断该不该用它有个简单标准:如果你正在为了对齐而给某个元素写死宽高,那就是 subgrid 的场景。CSS 视觉
字体、文本装饰、颜色、背景、边框与阴影,是把结构变成有质感界面的工具。这一章一半是每天都写的那批属性(下划线、大小写、对齐、缩进、列表标记,以及光标、选中、表单控件配色——坑全在反直觉处),一半是两件值得单独讲清楚的事:为什么现代 CSS 换了一套颜色写法(oklch 到底解决了什么问题),以及怎么在原生 CSS 里做深浅色主题——不靠任何框架。
排版的可靠性来自两件事:字体一定要能回退,行高一定要写成无单位。这两条踩错的代价分别是「换台机器全变样」和「嵌套元素行高全乱」。
font-family 是一个回退栈
- 浏览器逐个尝试,用第一个能提供该字符的字体,所以顺序就是优先级,末尾必须留通用族兜底(
sans-serif/serif/monospace); - 它还是逐字符回退的:中文字符在英文字体里找不到,会自动落到后面的字体——所以中英混排时把西文字体写在中文字体前面,才能让西文用上想要的字形;
system-ui直接取操作系统的界面字体,各平台都得到原生观感,是无品牌字体需求时最省事的首选;- 字体名含空格要加引号:
"Noto Sans SC"。
line-height 必须写无单位值
line-height: 1.6(无单位)继承的是这个倍数,每个后代按自己的字号重新算——这是唯一正确的做法;line-height: 1.6em或24px继承的是算好的结果:父元素 16px 算出 25.6px 之后,字号 32px 的h1也只有 25.6px 的行高,行会挤在一起甚至重叠;- 百分比同理(
160%也是先算成长度再继承),一样有问题; - 正文建议 1.5~1.7,大标题可以收到 1.1~1.2——字号越大,需要的相对行高越小。
字重、字距与换行
font-weight用数值更精确:400 =normal,700 =bold,可变字体还能取中间值如 500 / 600;letter-spacing:大标题适度收紧(如-0.02em)观感更紧凑;全大写文本反过来该放宽。用em而非px,字距才随字号缩放;text-wrap: balance让标题各行长度更均衡,text-wrap: pretty主要避免正文末行只剩一个孤字。这两个是较新的特性,当作渐进增强用——不支持时只是回到普通换行,不会坏掉。
@font-face 与 font-display
- 自定义字体用
@font-face声明,格式优先woff2; font-display决定字体加载期间怎么显示:swap先用回退字体渲染、字体到了再换(FOUT,文字闪烁一下但不会看不见);默认行为则倾向于短暂隐藏文字(FOIT,可能出现一段空白);- 正文一般选
swap——宁可字体跳变,也别让用户盯着空白;optional更激进,网络不佳时干脆放弃加载。
:root {
/* 西文在前、中文在后,末尾留通用族兜底 */
font-family: system-ui, -apple-system, "Segoe UI",
"Noto Sans SC", sans-serif;
line-height: 1.6; /* ✅ 无单位:后代按自己字号重算 */
/* line-height: 1.6em; ❌ 继承算好的像素值,大字号会挤 */
}
h1 {
font-size: clamp(1.75rem, 4vw, 3rem);
font-weight: 700;
line-height: 1.15; /* 字号越大,行高倍数越小 */
letter-spacing: -0.02em; /* 用 em,随字号缩放 */
text-wrap: balance; /* 渐进增强:不支持就普通换行 */
}
/* 自定义字体:woff2 + swap,避免加载期空白 */
@font-face {
font-family: "Inter";
src: url("/fonts/inter.woff2") format("woff2");
font-weight: 100 900; /* 可变字体的字重区间 */
font-display: swap; /* FOUT 而非 FOIT */
}line-height 带单位是排版里最隐蔽的坑:页面看着没问题,直到某个嵌套的大字号元素行距突然不对——继承下来的是父元素算好的像素值,不是倍数。永远写无单位。另外 letter-spacing 会在最后一个字符后面也加上间距,做居中的全大写按钮时右边会显得多出一点,需要补一个等量的负 margin-right 或改用 padding 微调。body 写 font-size: 16px 之类的绝对值——保留浏览器默认字号(通常是 16px),用户在浏览器里调大字号的设置才有效。需要缩放就用 rem。中英混排的字体栈推荐写成「西文字体, 中文字体, system-ui, sans-serif」的顺序。这一批属性天天在写,坑却全在反直觉的地方:下划线不是子元素能取消的、大写不只影响显示、text-align 管不了块级子元素、首行缩进会传染给嵌套的块。四条各自都能浪费半小时。
text-decoration:一个简写带四个属性
text-decoration-line:underline/overline/line-through/none,可以组合(underline overline);text-decoration-color、-style(solid/double/dotted/dashed/wavy)、-thickness(auto/from-font/ 具体长度);- 简写按任意顺序写在一起:
text-decoration: underline wavy #c00 2px; text-underline-offset不在简写里,必须单独写——它和-thickness是把默认那条又粗又贴的下划线调好看的两个旋钮;text-decoration-skip-ink: auto(默认)让下划线自动避开 g、j 这类下伸笔画;换成none才会一穿到底。这正是「下划线用border-bottom模拟」会丢掉的效果——要精确控距离用 border,要好看的避让用 text-decoration。
后代取消不掉祖先的下划线
- 装饰是由声明它的那个盒子画出来、盖在整段行内内容之上的,不是逐个元素各画各的。所以祖先写了
underline,后代写text-decoration: none无效; - (Chrome 150)这里的计算值会骗你:那个子
<span>的text-decoration-line计算值确确实实是none,可下划线照画——在 DevTools 里查计算值只会让你更迷惑; - 真要断开:让后代另起一个行内格式化上下文——
display: inline-block、浮动、绝对定位都可以,装饰就不再穿透过去; - 反过来,
<a>的下划线来自它自己而不是父级,直接写在a上就能改。
text-transform:三个值,两个边界,一个被推翻的说法
uppercase/lowercase/capitalize/none;capitalize是每个词的首字母而不是「首字母大写其余小写」;- (Chrome 150,16px sans-serif):
hello world宽 84.05px,加uppercase后 109.03px,与直接写HELLO WORLD的 109.03px 一模一样;同一段中文加不加都是 64px(对中文无效); capitalize的三个边界,hello-world→ Hello-World(连字符也算词边界);3rd place→ 3rd Place(数字开头的词首字母不会被大写,不是 3Rd);iPhone macOS→ IPhone MacOS(只动首字母,后面的大小写原样保留——所以品牌名会被写坏);- 一条常见说法被推翻:「CSS 大写只是显示效果,复制出来还是原文」——Chrome 150 里选中
uppercase的文本,getSelection().toString()拿到的是 "HELLO WORLD",也就是转换后的文本。各浏览器行为并不一致,所以别把「复制得到原文」当成保证,也别用它承载语义。
text-align 与 text-indent
text-align只管行内内容(文字、inline、inline-block)。父级写text-align: center,里面块级子元素的 left 仍然是 0——块级子元素居中用margin-inline: auto,这是最常见的误用;- 写
start/end而不是left/right,RTL 下自动镜像(18 章); justify300px 容器里前面的行被拉到整 300px,末行保持 160px 的自然宽度;要连末行一起拉需要text-align-last: justify(两行都是 300px)。中文正文两端对齐效果好,英文容易出现「河流」——配hyphens: auto能缓解;text-indent: 2em是中文首行缩进的标准写法,首行左边 32px、第二行 0;text-indent会继承,嵌套的块级子元素首行也缩进了 32px——「莫名多出一层缩进」多半是这么来的,在子块上写text-indent: 0归零。
列表标记:list-style 三件套
- 简写
list-style: type position image,日常最有用的是list-style-type(disc/decimal/cjk-ideographic/none,还能直接写字符串list-style-type: "→ "); list-style-position(ul的padding-left: 40px):outside(默认)文字从 x=40 开始、标记画在 padding 区里;inside文字从 x=62 开始,标记占掉 22px 的行内位置——代价是第二行会顶到标记正下方;::marker只能改有限的属性(color、font-*、content),做复杂符号仍然是list-style: none+::before;- 无障碍:给
ul写list-style: none后,Safari + VoiceOver 会不再把它当列表播报(「共 5 项」没了)。做导航这类去掉圆点的列表时补一个role="list"。
/* 好看的下划线:两个旋钮,注意 offset 不在简写里 */
a {
text-decoration: underline;
text-decoration-thickness: 1px;
text-underline-offset: 0.2em; /* 单独写 */
text-decoration-color: color-mix(in oklch, currentColor, transparent 60%);
}
a:hover { text-decoration-color: currentColor; }
/* ✗ 没用:装饰由祖先那个盒子画,后代取消不掉 */
.underlined span { text-decoration: none; }
/* ✓ 另起一个行内格式化上下文才断得开 */
.underlined span { display: inline-block; }
/* ✗ 居中不了块级子元素 */
.parent { text-align: center; }
/* ✓ 块级子元素自己让边距对称 */
.parent > div { margin-inline: auto; }
/* 中文首行缩进:记得给嵌套的块归零 */
.prose p { text-indent: 2em; }
.prose blockquote { text-indent: 0; }
/* 去掉圆点的导航列表:补回列表语义 */
nav ul { list-style: none; padding: 0; } /* HTML 上加 role="list" */text-align: center 居中不了块级子元素是写了多年 CSS 也会犯的错——它只对行内内容生效,块级子元素要靠 margin-inline: auto。症状是「文字居中了但那个 div 还贴在左边」。第二个是
text-indent 的继承传染:给 .prose p 写缩进没问题,写在 .prose 上就会让里面每个块级后代都缩一次(嵌套块首行同样是 32px)。第三个:用
text-transform: capitalize 处理人名、品牌名基本都会出错——iPhone 变成 IPhone、macOS 变成 MacOS,它只把首字母改大写,不会把后面的大写字母改回去。第四个:
list-style: none 在 Safari 里会连列表语义一起去掉,导航和面包屑这类地方记得补 role="list"。text-decoration-color 设成半透明的 currentColor,hover 时恢复实色(见代码栏)。导航栏、按钮那种靠位置和外观就能认出可点的地方,去掉下划线才是合理的。本页的色值几乎全是 oklch(),Tailwind v4 的默认调色板也整体换成了它。这不是赶时髦:hsl 的「亮度」并不等于人眼看到的亮度,而这一点会在你做色阶和自动生成主题色时,实实在在地把设计搞砸。
四种写法
| 写法 | 分量 | 适合 |
|---|---|---|
#rrggbb | 红绿蓝十六进制,可加两位写成 #rrggbbaa 带透明度 | 从设计稿复制粘贴;人眼无法从数值推断颜色 |
rgb(r g b / a) | 0–255 或百分比 | 与图像 / canvas 打交道;同样不直观 |
hsl(h s% l% / a) | 色相 饱和度 亮度 | 能手调;但不同色相下同一 l% 的实际明暗差很大 |
oklch(l c h / a) | 感知亮度 彩度 色相 | 感知均匀,做色阶 / 混色 / 自动主题的首选 |
hsl 的问题:同一个 lightness,明暗却不一样
- hsl 的
l是在 sRGB 数值上机械算出来的,不考虑人眼对不同色相的敏感度差异; - 最直观的例子:
hsl(60 100% 50%)(黄)和hsl(240 100% 50%)(蓝)名义亮度都是 50%,但黄色看起来明晃晃、蓝色看起来很暗; - 后果就是:你按固定步长生成一套 50→900 的色阶,黄色系和蓝色系的观感明暗完全对不齐;把品牌色换个色相自动生成主题,深浅关系整个乱掉;
oklch的l基于感知均匀的色彩空间,相同l的不同色相看起来明暗一致——固定l只改h,就能得到一整排明度协调的颜色。这正是自动生成调色板需要的性质;- 附带好处:
oklch能表达超出 sRGB 的广色域颜色(在支持广色域的屏幕上更鲜艳),而 hex /rgb()/hsl()都被限制在 sRGB 内。
三个日常好用的补充
- 透明度统一用斜杠:
rgb(0 0 0 / 50%)、oklch(0.55 0.18 280 / 0.5)。旧的rgba()/hsla()仍可用,但新写法把「颜色」和「透明度」分得更清楚; color-mix()按比例混两色,还能指定在哪个空间里混:color-mix(in oklch, var(--brand) 80%, white)得到的浅色比在 sRGB 里混更自然。用它从一个品牌色派生 hover 态、禁用态,比手写一堆色值好维护;currentColor取当前元素的color值,可以用在border-color/box-shadow/ SVG 的fill上——一个图标按钮只要改color,文字、边框、图标就一起变,是做主题最省事的关键字。
color: #534ab7; /* hex,设计稿直接抄 */
color: #534ab780; /* 末两位是 16 进制透明度,80≈50% */
color: rgb(83 74 183 / 50%); /* 现代语法:空格 + 斜杠 */
color: hsl(245 42% 50%); /* 好手调,但明暗不感知均匀 */
color: oklch(0.55 0.18 280); /* 感知亮度 彩度 色相 */
/* 固定 l 与 c,只转 h:得到一排明暗观感一致的颜色 */
.a { background: oklch(0.65 0.15 30); } /* 红 */
.b { background: oklch(0.65 0.15 150); } /* 绿 */
.c { background: oklch(0.65 0.15 260); } /* 蓝,三者深浅一致 */
/* color-mix:从一个主色派生状态色,换主色只改一处 */
:root { --brand: oklch(0.55 0.18 280); }
.btn { background: var(--brand); }
.btn:hover { background: color-mix(in oklch, var(--brand), black 12%); }
.btn:disabled { background: color-mix(in oklch, var(--brand), gray 60%); }
/* currentColor:图标与边框跟着文字色走 */
.icon-btn { color: var(--brand); border: 1px solid currentColor; }
.icon-btn svg { fill: currentColor; }#rrggbbaa 的透明度是十六进制不是百分比:50% 约等于 80,不是 50——手写很容易错,需要透明度时优先用 / 语法。另一个常见误解是把 oklch 的 c(彩度)当成 hsl 的饱和度百分比:它没有固定上限,实际可取的最大值随 l 和 h 变化,写太大只会被裁剪到色域边界,看起来和小一点的值没区别。最后,currentColor 取的是当前元素自己的 color,如果你在同一条规则里同时改 color 和用 currentColor,用的是新值不是继承值。color-mix(in oklch, ...) 算出来,而不是再要一个 hex。这样换主色时只改一处。图标和边框尽量用 currentColor,能省掉大量重复声明。background 是一个能叠很多层的属性,而渐变本质上是图像不是颜色——这两句话解释了背景相关的大部分困惑。
多重背景:先写的在上面
- 用逗号分隔可以叠任意多层图像,层序是「写在前面的离用户更近」——和
z-index数值越大越靠前的直觉相反,容易记反; - 典型用法是拿一层半透明渐变压在照片上当蒙版,保证上面的白字能读清;
background-color永远在最底层,无论写在哪个位置;- 其它子属性(
-size/-position/-repeat)如果写成逗号列表,会按顺序和各层一一对应;数量不够时循环复用。
size 与 position
cover:等比放大到铺满容器,超出的部分被裁掉——头图、卡片封面用它;contain:等比缩放到完整可见,可能在两侧留白——需要看到完整图形(logo、插画)时用它;- 配合
background-position: center才能让裁切发生在两侧而不是只裁右下; - 简写里可以用斜杠一起写:
url(a.jpg) center / cover no-repeat——注意position必须写在size前面,只写/ cover而省略位置是无效的。
渐变:色标、中点与插值空间
- 三种:
linear-gradient(线性)、radial-gradient(径向)、conic-gradient(锥形,绕中心旋转,做饼图 / 色轮 / 进度环很方便); - 色标可以带位置:
linear-gradient(90deg, red 20%, blue 80%)——20% 之前是纯红、80% 之后是纯蓝; - 两个色标之间可以插入一个裸百分比作为颜色中点:
linear-gradient(red, 30%, blue)表示「红蓝各半的位置挪到 30%」,用来把渐变调得不那么呆板; - 同一个色标位置写两次可以做出硬边(无过渡的分段色带):
linear-gradient(red 50%, blue 50%); - 插值空间:默认在 sRGB 里插值,蓝→黄之类的跨色相渐变中段会发灰。可以指定
linear-gradient(in oklch, blue, yellow)让中段更鲜活。这个语法较新,当渐进增强用。
background-clip: text
- 把背景裁到文字形状上,配合渐变就是「渐变文字」;
- 前提是文字自身必须透明(
color: transparent),否则文字色会盖住背景; - 老代码里常见
-webkit-background-clip: text的前缀写法,现代浏览器已支持无前缀的background-clip: text,只在需要兼容老 WebKit 时才补前缀; - 注意这样做出来的文字对比度不可控,正文别用,只用于装饰性大标题。
/* 多层背景:先写的在上面;background-color 永远在最底 */
.hero {
background:
linear-gradient(to top, #000c, #0000 60%), /* 蒙版在上 */
url("photo.jpg") center / cover no-repeat; /* 位置在 size 前 */
background-color: oklch(0.2 0.02 280); /* 图没加载出来时的兜底 */
}
/* 色标位置、颜色中点与硬边 */
.a { background-image: linear-gradient(90deg, red 20%, blue 80%); }
.b { background-image: linear-gradient(red, 30%, blue); } /* 裸百分比=颜色中点 */
.c { background-image: linear-gradient(red 50%, blue 50%); } /* 硬边分段 */
/* 指定插值空间,跨色相渐变中段不发灰(较新,渐进增强) */
.d { background-image: linear-gradient(in oklch, blue, yellow); }
/* 锥形渐变:做进度环 / 色轮 */
.ring { background-image: conic-gradient(var(--brand) 70%, #e5e7eb 0); }
/* 渐变文字:文字必须透明 */
.gradient-text {
background-image: linear-gradient(90deg, #534ab7, #7c3aed);
-webkit-background-clip: text;
background-clip: text;
color: transparent; /* 少了这行什么都看不到 */
}<image> 的地方(background-image、border-image、mask-image),写进 color 或 border-color 一律无效——想要渐变文字得走上面的 background-clip: text,想要渐变边框得用 border-image 或叠两层背景。另一个坑:多重背景的层序是先写的盖在上面,把蒙版渐变写在图片后面就完全看不见效果了。text-shadow,而是在照片上面叠一层半透明渐变——从底部的 #0009 渐变到中部的透明,既保证了对比度又不糊掉画面。这是最常用的多重背景场景。边框和阴影里藏着两组值得分清的对照:outline 与 border(一个不占空间、一个占),box-shadow 与 filter: drop-shadow()(一个贴边框盒、一个贴实际形状)。选错了就得反复调数值。
border-radius 的完整语法
- 一到四个值按左上 → 右上 → 右下 → 左下顺时针给四个角;
- 用
/能分别指定水平半径和垂直半径:border-radius: 50px / 20px得到扁椭圆形的角,斜杠前是水平、后是垂直——做胶囊、叶子形状全靠它; 50%把方形变正圆;要做「胶囊按钮」用一个很大的固定值(如999px)比50%更稳,因为百分比在长条元素上会变成椭圆;- 圆角只裁切元素自己的背景与边框,对子元素没有任何裁剪作用——子元素的背景会照常画到圆角外面。要让子元素也跟着圆角被裁,必须在父元素上加
overflow: hidden(或clip)。
outline vs border:焦点环该用哪个
border占空间:加上去元素会变大(除非box-sizing: border-box),并且参与布局;outline不占空间、不影响布局,画在边框外侧,加了也不会把周围元素挤开;- 所以焦点环必须用
outline:聚焦时加一圈 border 会让页面元素跳动,用 outline 则完全不动版; outline-offset能把这圈线往外推,留出呼吸感;现代浏览器的 outline 会跟随border-radius的圆角;- 绝对不要写
outline: none却不给替代样式——键盘用户会彻底看不见焦点在哪。要去掉默认样式就用:focus-visible画自己的(见 04 章)。
box-shadow 与 drop-shadow
box-shadow: x偏移 y偏移 模糊 扩散 颜色,加inset变成内阴影(做凹陷感、输入框内描边);- 可以逗号叠多层,同样是先写的在上面。真实感来自「一层很近很淡 + 一层很远更淡」的组合,而不是单层大模糊;
box-shadow贴着边框盒(会跟随border-radius),所以给一张带透明区域的 PNG 加box-shadow,得到的是一个矩形阴影;filter: drop-shadow()则跟随元素的实际不透明形状——透明 PNG、SVG 图标、用clip-path裁过的元素,都能得到贴合轮廓的阴影。代价是它没有扩散半径参数,且作为滤镜开销更大;- 选择:规则的卡片 / 按钮用
box-shadow;不规则形状、透明图、图标用drop-shadow。
.card {
border: 1px solid #e5e7eb;
border-radius: 12px;
overflow: hidden; /* 让子元素也被圆角裁切 */
/* 一近一远两层,比单层大模糊真实得多;先写的在上 */
box-shadow:
0 1px 2px rgb(0 0 0 / 5%),
0 8px 24px rgb(0 0 0 / 8%);
}
/* 双半径:斜杠前水平、后垂直 */
.leaf { border-radius: 50px / 20px; }
.pill { border-radius: 999px; } /* 胶囊,比 50% 稳 */
.avatar { border-radius: 50%; }
/* 焦点环用 outline:不占空间、不推挤布局 */
.btn:focus-visible {
outline: 2px solid var(--brand);
outline-offset: 2px;
}
/* 不占空间的 1px 描边 + 内描边 */
.ring { box-shadow: 0 0 0 1px rgb(0 0 0 / 10%); }
.inner { box-shadow: inset 0 1px 2px rgb(0 0 0 / 12%); }
/* 透明 PNG / SVG 图标:要贴合轮廓就用 drop-shadow(无扩散参数) */
.logo { filter: drop-shadow(0 2px 4px rgb(0 0 0 / 25%)); }filter: drop-shadow() 用起来像 box-shadow,但它没有第四个「扩散」参数——按 box-shadow 的习惯写四个长度值会导致整条声明无效。另外任何非 none 的 filter 都会给元素建立新的包含块,里面 position: fixed 的子元素会相对该元素定位而不是视口(见 07 章),这个副作用经常在加了滤镜之后才莫名其妙冒出来。最后,outline: none 是无障碍红线,去掉默认焦点环就必须补一个自己的。box-shadow: 0 0 0 1px 颜色 代替 border——它不影响布局,还能和 outline 叠在一起做双层焦点环(内圈品牌色 + 外圈白色,保证在任何背景上都可见)。inset 版则是画内描边的常用手法。做暗色模式不需要任何框架。原生方案的核心是三件事:用自定义属性把颜色抽成令牌、在媒体查询里翻转令牌、别忘了 color-scheme。最后一条是最常被漏掉的一条。
第一步:颜色令牌化
- 别在组件里直接写色值,先在
:root上定义一层语义化令牌:--bg/--surface/--text/--border/--brand; - 组件只引用令牌。这样切主题就是换一组令牌的值,组件 CSS 一行不用改;
- 令牌名要按用途而不是外观来起——叫
--text-muted而不是--gray-500,否则暗色下「gray-500 其实变亮了」会很别扭。
第二步:color-scheme——最常漏的一步
prefers-color-scheme只是让你的 CSS 变了色,浏览器自带的 UI 不知道;- 结果是:页面已经是深色,但表单控件、下拉框、滚动条、日期选择器仍然是刺眼的浅色,还有
input的默认文字色可能变得几乎看不见; - 解法是声明
color-scheme,告诉浏览器该用哪套原生配色::root { color-scheme: light dark; }表示两种都支持、跟随系统; - 它也会影响未指定颜色时的默认前景 / 背景色。做暗色模式一定要写这一行,否则总有几个控件看着不对劲;
- 手动切换主题时,记得同时改
color-scheme(改成单值dark或light),否则原生控件仍然跟着系统走。
第三步:翻转与手动覆盖
- 纯「跟随系统」最简单:把令牌的深色版本放进
@media (prefers-color-scheme: dark); - 要支持用户手动切,通常做成三态:
light/dark/system(默认)。在<html>上挂data-theme属性,用[data-theme="dark"]选择器覆盖令牌;「system」态就是不设这个属性,让媒体查询接管; - 选择器的特异性要拿捏:
[data-theme]的规则必须能盖住媒体查询里的:root规则——把它写在媒体查询之后,或者用:root[data-theme="dark"]提高特异性; light-dark()函数可以在一个声明里同时写两套值:color: light-dark(#111, #eee),由color-scheme决定取哪个。它较新,且必须配合color-scheme才有意义——可以作为渐进增强,但需要广泛兼容时仍用媒体查询方案。
FOUC:先闪一下白再变黑
- 手动主题存在
localStorage里,如果等到页面渲染后才用 JS 读出来应用,用户会先看到一帧默认主题——深色偏好用户体验尤其差; - 解法是把「读 localStorage 并在
<html>上设好属性」的那几行 JS,内联在<head>里、样式表之前同步执行,赶在首次绘制之前; - 这段脚本要尽量短、不依赖任何外部资源,否则它自己就成了阻塞点;
- Tailwind 侧的同一问题与双策略(class / data 属性)见 16 章。
/* 1. 令牌按用途命名 + 声明 color-scheme(关键的一行) */
:root {
color-scheme: light dark; /* 原生控件/滚动条也跟着变 */
--bg: oklch(0.99 0 0);
--surface: oklch(0.97 0.005 280);
--text: oklch(0.22 0.01 280);
--border: oklch(0.90 0.01 280);
}
/* 2. 跟随系统:翻转令牌 */
@media (prefers-color-scheme: dark) {
:root {
--bg: oklch(0.17 0.01 280);
--surface: oklch(0.22 0.015 280);
--text: oklch(0.92 0.01 280); /* 不用纯白,太刺眼 */
--border: oklch(0.32 0.015 280);
}
}
/* 3. 手动覆盖:写在媒体查询之后,特异性更高;同时改 color-scheme */
:root[data-theme="light"] { color-scheme: light; --bg: oklch(0.99 0 0); --text: oklch(0.22 0.01 280); }
:root[data-theme="dark"] { color-scheme: dark; --bg: oklch(0.17 0.01 280); --text: oklch(0.92 0.01 280); }
/* 不设 data-theme = system 态,由上面的媒体查询接管 */
body { background: var(--bg); color: var(--text); }
/* 较新写法:一个声明写两套值,取哪套由 color-scheme 决定 */
.note { color: light-dark(#111827, #e5e7eb); }
/* 防 FOUC:这段必须内联在 <head> 里、样式表之前同步跑 */
// <script>
// const t = localStorage.getItem('theme');
// if (t === 'light' || t === 'dark')
// document.documentElement.dataset.theme = t;
// </script>color-scheme 是暗色模式的头号返工点:滚动条、select 下拉、日期选择器这些浏览器原生 UI 只认 color-scheme,不认你的 CSS 变量。还有一个是把主题切换的 JS 放在 <body> 末尾或异步加载——必然闪白屏。第三个是媒体查询与手动覆盖的顺序 / 特异性写反:手动选了浅色,系统是深色,结果媒体查询把它盖回去了。:root { color-scheme: light dark; } 加一个 @media (prefers-color-scheme: dark) 翻转令牌——先把「跟随系统」做对,再考虑要不要加手动切换。暗色不是把颜色简单反相:深色背景上纯白文字对比过强会发晕,用 oklch 略降亮度(如 0.92 而不是 1)更合适;阴影在暗色下几乎看不见,改用更亮的边框来划分层次。同一段文字,排版好不好看差别很大,而这件事基本不需要 JS,也不需要手动插换行。这一卡把「在哪断行」的全套控制项摆在一起——它们各管一件事,混用是最常见的错误。
text-wrap: balance:把几行排得一样长
- 专治标题「最后一行只剩一个词」的孤字问题。浏览器会试算,让各行宽度尽量接近;
- (Chrome 150,230px 容器、16px sans-serif 英文标题):普通换行三行宽度为 200 + 203 + 41px(末行只有一个词),加
text-wrap: balance后变成 150 + 144 + 150px——三行几乎齐平; - 只用在标题和短句上。规范允许浏览器对超过若干行的文本放弃 balance(成本是行数的平方级),正文段落写了往往没效果;
- 同一份中文标题加不加 balance 结果完全一样(224 + 215px)——中文没有词间空格,断点密度本来就高,孤字问题天然轻得多。这条别按英文经验想当然。
text-wrap: pretty:正文用这个
- 它不追求各行等长,而是避免孤字、连续多行连字符结尾这类瑕疵,代价小得多,适合长正文;
- 一条实用分工:标题
balance、正文pretty,写成两条全局规则一劳永逸(见代码栏)。
四个「长内容溢出」的属性,别弄混
overflow-wrap: anywhere/break-word——只在放不下时才允许在单词内部断开,正常文本仍按词断。这是长网址、长 token 溢出容器的标准解法;word-break: break-all——不管放不放得下,一律逐字符断。英文正文用它会惨不忍睹,它是给 CJK 混排的少数场景准备的;hyphens: auto——按语言的连字规则加连字符断词,必须配lang属性(02 章那条「<html lang>不是装饰」在这里兑现),中文无效;white-space: nowrap——完全不换行,配overflow: hidden; text-overflow: ellipsis才是单行截断;多行截断要-webkit-line-clamp(Tailwind 里truncate与line-clamp-3正好对应这两件事,见 16 章);- 差别在于「什么时候动手」:
overflow-wrap是最后手段,word-break是无条件执行——搞混的症状是「英文单词被切得七零八落」。
还有两个细节控制
text-box-trim/text-box-edge:裁掉字体自带的上下空隙,让文字块的视觉边界和盒子边界对齐——按钮里的文字终于能真正居中。机制与数字在 18 章;text-spacing-trim(中日韩标点挤压)与font-variant-numeric: tabular-nums(等宽数字,表格里数字对齐必用)——后者是全站表格最值得加的一条。
/* 一劳永逸的两条全局规则 */
h1, h2, h3, h4, .headline { text-wrap: balance; }
p, li, .prose { text-wrap: pretty; }
/* 长网址 / token:放不下才断,正常文本不受影响 */
.break-anywhere { overflow-wrap: anywhere; }
/* ⚠️ 这个是无条件逐字符断,英文正文别用 */
.cjk-only { word-break: break-all; }
/* 连字断词:必须有 lang,中文无效 */
article[lang="en"] { hyphens: auto; }
/* 单行截断(三件套缺一不可) */
.ellipsis {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* 多行截断 */
.clamp-3 {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
overflow: hidden;
}
/* 表格里的数字对齐:一行大幅提升可读性 */
td.num { font-variant-numeric: tabular-nums; text-align: right; }word-break: break-all 是被误用最多的一条:很多人为了「防止长网址溢出」全局写上它,结果整篇英文正文的每个词都可能被拦腰截断,而中文本来就能任意断、根本不需要它。要防溢出请用 overflow-wrap: anywhere——它只在真放不下时才拆词。第二处:
-webkit-line-clamp 那一套必须四条一起写(display: -webkit-box + -webkit-box-orient: vertical + -webkit-line-clamp + overflow: hidden),少任何一条都完全不生效;而且 display 被占用后,这个元素不能再是 flex/grid 容器——需要的话套一层。第三个是一条被推翻的常见说法:
text-wrap: balance 与 -webkit-line-clamp 并不互相抵消。同一段文字,只写 balance 时高 72px(三行),balance 加 line-clamp: 2 后是 48px,与只写 clamp 完全一样——截断照样生效。真正需要注意的是顺序上的取舍:balance 会先把行排匀,再被截掉后面几行,于是「被砍掉的那一行」可能和你按普通换行预期的不同。<br> 控制标题换行。它在窄屏上会断在完全错误的位置,而 text-wrap: balance 在任何宽度下都自动重算。真需要「在这里必须断」时用 <wbr>(可选断点,只在需要时生效)或者零宽空格;真需要「这两个词绝不能分开」时给它们包一层 white-space: nowrap,或者用不换行空格 ——比如「第 3 章」「10 kg」这种数字和单位之间。这一卡管的是「鼠标和键盘碰上元素时它长什么样」。属性都很简单,但有三条事实值得先记住:按钮默认不是手型光标、pointer-events: none 挡不住键盘、appearance: none 会把复选框缩成 0×0。
cursor:浏览器的默认值和你以为的不一样
Chrome 150 的计算值:
| 元素 | 默认 cursor | 说明 |
|---|---|---|
<button> | default | 不是 pointer——手型得自己写 |
<a href> | pointer | 有 href 才算链接 |
<a> 无 href | auto | 退化成普通行内元素 |
<input type="text"> | text | I 型光标 |
<select> | default | 同按钮 |
<button disabled> | default | 禁用态不会自动变 not-allowed |
- 所以
button, [role="button"] { cursor: pointer }是每份 reset 都该有的一行;规范里pointer的语义偏向「这是个链接」,也有团队刻意只给链接用,两种做法都成立,但同一个项目里要统一; - 值得记住的值:
text、move、grab/grabbing(拖拽两态)、not-allowed(禁用)、progress(后台在忙但仍可交互)、wait(完全阻塞)、col-resize/row-resize(分栏拖条)、zoom-in、none(隐藏光标,播放器用); - 自定义光标
cursor: url(cur.png) 12 12, pointer:两个数字是热点坐标,末尾必须留一个关键字兜底,否则整条声明无效;图片太大(普遍以 128×128 为界)会被浏览器直接忽略。
pointer-events: none 不是「禁用」
- 给按钮写上
pointer-events: none后调focus(),document.activeElement就是这个按钮——它照样能被 Tab 到、照样能用空格/回车触发。只挡鼠标,不挡键盘; - 真正的禁用是表单控件的
disabled属性,整块区域则用inert(04 章); - 它的正当用途:装饰性覆盖层(让点击穿透到下层)、正在做进场动画的元素防误触、图标不抢走按钮的点击目标;
- 子元素可以写
pointer-events: auto单独恢复——「整块穿透、只留一个按钮可点」就是这么做的。
user-select 与选中
- 取值
auto/text/none/all(all是「一点就整块选中」,适合要整段复制的 token); user-select: none的元素用 Selection API 选中后toString()是空串;而它内部写了user-select: text的子元素又能取回全文——可以逐层开关;- 它是防误选,不是防复制:DevTools、阅读模式、「查看源代码」、无障碍树全都拿得到文本。拿它当内容保护是自欺欺人,只会顺带把正常用户的复制需求也砍了;
- 该用的地方就三类:按钮文字、拖拽把手、代码块的行号——双击这些东西时选中一片蓝是纯粹的噪声;
- 配套两个:
::selection改选中区配色(06 章),caret-color改输入光标颜色(caret-color: transparent可以把光标藏起来,做自定义输入框时会用到)。
表单控件配色:accent-color 一行顶过去几百行
accent-color给checkbox/radio/range/progress直接上品牌色(写accent-color: red后 range 的计算值就是rgb(255, 0, 0)),浏览器还会自动挑一个对比度合适的对勾颜色;- 这一条几乎废掉了「
appearance: none隐藏原生控件 + 自己画一个」的老套路——那套做法要把键盘操作、焦点环、无障碍语义全部重新实现一遍,而收益往往只是换个颜色; appearance: none的代价很直接:默认 13×13 的复选框加上appearance: none后变成 0×0(原生外观连同尺寸一起没了),必须自己补width/height/border才能看见;- 需要重度定制时,先看 04 章那三个新原语:
field-sizing: content(输入框按内容伸缩)、appearance: base-select(<select>变成可完全样式化又保留原生行为)、::picker()——它们的目标正是让你不必再从零重造控件。
resize 与滚动条外观
resize: none/both/horizontal/vertical(还有逻辑值block/inline)。<textarea>默认就是both(计算值),实战几乎都改成vertical——横向拉伸很容易把布局撑破;- 规范规定
resize只对overflow非visible的元素生效,所以普通<div>想能拖拽,得同时写overflow: auto。注意计算值不会告诉你有没有生效:overflow: visible的 div,resize计算值照样是both,查 DevTools 只会更困惑; - 滚动条:
scrollbar-width: thin(还有none)与scrollbar-color: 拇指色 轨道色是标准写法,比::-webkit-scrollbar那套跨浏览器;把滚动条藏了要确认还有别的滚动线索,否则用户不知道这里能滚。scrollbar-gutter见 18 章; - 移动端还有一个
touch-action:manipulation表示「这里只需要滚动和点击」,让浏览器不必等待双击缩放的手势判定;none则会把该区域的浏览器手势全关掉,用它前先想清楚是不是把缩放也一起禁了。
/* reset 里值得有的两行 */
button, [role="button"], label { cursor: pointer; }
:disabled { cursor: not-allowed; } /* 浏览器不会自动给 */
/* 一行给全站表单控件上品牌色 */
:root { accent-color: oklch(0.55 0.18 280); }
/* 装饰层穿透,只留里面的按钮可点 */
.overlay { pointer-events: none; }
.overlay button { pointer-events: auto; }
/* ⚠️ 这不是禁用:按钮仍能 Tab 到并触发 */
/* 防误选:只用在这三类地方 */
.btn, .drag-handle, .line-number { user-select: none; }
/* textarea 默认 both,横向拉伸会撑破布局 */
textarea { resize: vertical; field-sizing: content; }
/* div 想能拖拽:resize 必须配非 visible 的 overflow */
.panel { resize: both; overflow: auto; }
/* 细滚动条(跨浏览器写法) */
.scroller {
scrollbar-width: thin;
scrollbar-color: color-mix(in oklch, currentColor, transparent 70%) transparent;
}appearance: none 是个比想象中重的开关:复选框加上它直接变成 0×0,从此尺寸、边框、对勾、焦点环、按下的动画全部要自己重造,还得处理 :checked / :indeterminate / :focus-visible 三种状态。只想换颜色就用 accent-color,别开这个头。第二个:拿
pointer-events: none 当禁用——它挡不住键盘,用户 Tab 过去回车一按照样提交。第三个:拿
user-select: none 当内容保护,除了惹恼想复制一段文字的正常用户之外没有任何效果。第四个:
cursor: url() 忘了写兜底关键字,图片一旦加载失败或过大,整条声明作废、光标回到默认——症状是「在我机器上是好的」。cursor、user-select、pointer-events 全是外观层的属性,没有一个能改变元素在无障碍树里的身份:pointer-events: none 的按钮仍在 Tab 序里,cursor: not-allowed 的按钮照样能点。真正的状态要写在 HTML 上——disabled、aria-disabled、inert——然后再用这些属性把外观对齐。顺序反了,就会做出「看着不能点、其实能点」的控件。CSS 动效
动效不是装饰,它承担着「告诉用户刚刚发生了什么」的职责。本章讲两套机制(transition 应对状态变化、animation 应对自主播放),再讲两条贯穿始终的纪律:性能上只动 transform 和 opacity,无障碍上尊重 prefers-reduced-motion。后两张卡比前两张更重要。
transition 的模型很简单:某个属性的值变了,别瞬间跳过去,用一段时间平滑走过去。用不好的原因通常只有两个——属性根本不可过渡,或者 transition 写错了地方。
可过渡 = 可插值
- 浏览器必须能算出「中间状态」才能做过渡。数值类(长度、颜色、透明度、
transform的各分量)都可以; - 离散型的属性传统上不可过渡:
display、visibility(部分行为特殊)、position——它们没有中间值,只会在某个时刻直接跳变; height: auto是个经典的过不去的坎:auto不是具体数值,从0到auto无法插值。传统变通是过渡max-height(配一个足够大的值,缺点是时间曲线不准),或者改用transform: scaleY()(不影响布局,性能也更好);- 过渡
grid-template-rows从0fr到1fr是较新的展开动画写法,可作为渐进增强。
简写的顺序规则
transition: 属性 时长 缓动 延迟,其中两个时间值按出现顺序解析:第一个是时长,第二个是延迟——这是唯一需要背的一条;- 属性名和缓动函数不会混淆,位置相对自由,但按上面的顺序写最好读;
- 多个属性用逗号分隔,各写各的时长:
transition: opacity .2s, transform .3s ease-out; - 缓动常用
ease-out(进入快、收尾缓,适合元素出现)、ease-in(适合元素消失)、cubic-bezier()自定义。别用linear做位移,现实世界没有匀速启停,看起来会很机械; - 时长的经验区间:小状态变化(颜色、透明度)150–250ms,较大的位移 250–400ms。界面反馈拖得越久越像卡顿,交互类动效宁短勿长。
allow-discrete 与 @starting-style(较新)
transition-behavior: allow-discrete让display这类离散属性也能参与过渡:淡出时display: none会推迟到过渡结束才生效,而不是立刻把元素抹掉;@starting-style规则用来指定元素刚出现时的起始值,解决了「元素从display: none变出来时没有起点、所以没有进入动画」的老问题;- 两者配合,纯 CSS 就能做出弹窗 / 提示的进出动画,不再需要 JS 加类名两步走;
- 这套组合真的成立(Chrome 150,一个
popover配transition: opacity .3s, display .3s allow-discrete):关闭时display: none/opacity: 0;调showPopover()后第二帧读到display: block而opacity才 0.039(说明@starting-style确实提供了起点,动画从 0 开始跑),350ms 后到 1;调hidePopover()后第二帧display仍是 block、opacity0.961,退场动画跑完才变回none——allow-discrete的「推迟」肉眼看不见,但读计算值一目了然; - 它们比较新,请当渐进增强用:不支持时元素仍会正常出现,只是没有动画。
过渡到 height: auto:曾经的不可能
- 「展开折叠面板」这件事以前必须用
max-height猜一个够大的值,或者用 JS 量出高度再赋值——因为auto不是一个可插值的数; interpolate-size: allow-keywords(写在:root上一劳永逸)让auto/min-content/fit-content这些内在尺寸关键字也能参与过渡;也可以用calc-size(auto, size)显式取到那个值再运算;- 对照最能说明问题:同一个
transition: height .5s的盒子从height: 0改到height: auto——裸写时getAnimations()是 0 条、第二帧就直接是终值 72px(等于没有动画);加上interpolate-size: allow-keywords后 1 条动画、第二帧 4.78px、250ms 时 40.78px,稳稳走完; calc-size()还能参与计算:height: calc-size(auto, size)解出 72px,calc-size(auto, size / 2)解出 36px。
/* ✅ 写在基础态:进出都平滑 */
.btn {
transition: transform .2s ease-out,
background-color .2s ease-out .05s; /* 第2个时间=延迟 */
}
.btn:hover { transform: translateY(-2px); }
/* ❌ 写在 :hover 里:移出时规则失配,会直接弹回 */
/* .btn:hover { transition: transform .2s; transform: translateY(-2px); } */
/* ❌ 不要 transition: all —— 会连累未预期的属性 */
/* height:auto 过不去 → 改用不影响布局的 scaleY */
.panel {
transform: scaleY(0); transform-origin: top;
transition: transform .25s ease-out;
}
.panel.open { transform: scaleY(1); }
/* 较新:让 display 也能参与过渡,并给出进入动画的起点 */
.dialog {
opacity: 1;
transition: opacity .2s, display .2s allow-discrete;
}
.dialog[hidden] { opacity: 0; display: none; }
@starting-style {
.dialog { opacity: 0; } /* 刚出现时的起始值 */
}transition 要写在基础态上,不要写在 :hover 里。写在 .btn:hover 上时,鼠标移入因为匹配了这条规则所以有过渡,移出的瞬间规则不再匹配,transition 声明随之消失,元素会「啪」地弹回原状——一半有动画一半没有,非常突兀。写在 .btn 上则进出都平滑。反过来,如果你故意想让进出速度不同(比如出现慢、消失快),才在两处分别写不同时长。transition: all。all 会把你没预料到的属性(包括第三方样式、布局属性)也一起过渡,既拖性能又制造诡异动画,还让调试变得困难。列清楚要动哪几个属性是更专业的写法。transition 需要一个「状态变化」来触发,animation 则自己会播——加载指示器、循环脉冲、进场动画都归它。而 transform 是这两者共同的主力工具,它有一个几乎人人踩过的性质:函数的书写顺序会改变结果。
@keyframes 与 animation 简写
@keyframes 名字 { from {...} to {...} },也可以用百分比给多个中间帧(0%/50%/100%);animation简写可以带:名称、时长、缓动、延迟、次数(infinite)、方向(alternate来回播)、填充模式、播放状态;- 和
transition一样,两个时间值里第一个是时长、第二个是延迟; - 动画期间的样式优先级高于普通声明,所以
@keyframes里写的值会盖住元素本身的同名属性——这是设计如此,不是 bug。
animation-fill-mode:最常被需要却最常漏
- 默认
none:动画播完就把样式全部还原到元素本来的状态。所以「淡入动画播完,元素又变回透明了」是最经典的困惑; forwards:停在最后一帧——绝大多数「进场动画」要的就是这个;backwards:在延迟期间就先应用第一帧的样式,避免「延迟期间元素以原样式闪一下」;both:前两者都要,有延迟的进场动画直接用它最省心;- 口诀:一次性的进场动画写
forwards或both,循环动画不用管。
transform 的函数顺序会改变结果
- 多个变换函数从左到右依次应用,而且每一步都会带着元素的坐标系一起变;
transform: translateX(100px) rotate(45deg):先平移 100px,再就地旋转;transform: rotate(45deg) translateX(100px):先旋转,坐标轴也转了 45°,于是这 100px 是沿着斜方向走的——两者结果完全不同;- 这不是 bug,是矩阵乘法不满足交换律的直接体现。调不出想要的效果时,先试试把顺序调换;
transform-origin决定变换的基准点(默认元素中心50% 50%)。做「从顶部展开」要写transform-origin: top,做绕角旋转要写对应的角。
独立的 translate / rotate / scale 属性
- 现在
translate、rotate、scale已经是各自独立的 CSS 属性,不必再挤在transform简写里; - 好处很实在:可以单独动画其中一个而不影响另外两个——以前想「悬停时只放大、保持原有位移」,必须把位移一起重写进
transform,漏写就会被重置; - 它们的应用顺序是固定的 translate → rotate → scale,之后才轮到
transform属性里的函数; - 需要精确控制复合顺序时,仍然用
transform一条写全。
@keyframes spin { to { rotate: 360deg; } }
.loader { animation: spin 1s linear infinite; } /* 循环,无需 fill-mode */
@keyframes fade-up {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.card {
/* 名称 时长 缓动 延迟 次数 填充;两个时间值=时长、延迟 */
animation: fade-up .4s ease-out .1s 1 both;
/* ⚠️ 少了 both/forwards,播完会弹回 opacity:0 */
}
/* 顺序不同,结果完全不同 */
.a { transform: translateX(100px) rotate(45deg); } /* 先移后就地转 */
.b { transform: rotate(45deg) translateX(100px); } /* 转完沿斜向移 */
.panel { transform-origin: top; } /* 基准点,默认是中心 */
/* 独立属性:只改缩放,不会把已有位移重置掉 */
.thumb { translate: 0 -4px; scale: 1; transition: scale .2s; }
.thumb:hover { scale: 1.05; } /* 位移仍在,不用重写 */
.loader:hover { animation-play-state: paused; }animation-fill-mode: forwards 是进场动画的头号问题:动画播完样式被还原,元素「闪现后消失」。第二处是 transform 函数顺序写反,结果元素飞到了意想不到的位置。第三个:transform 对行内元素(display: inline)不生效——给一个没设 display 的 <span> 加 transform 会毫无反应,需要先改成 inline-block 或 block。animation ... infinite;一次性的状态反馈优先用 transition,因为它天然有「回去」的路径,而 animation 播完还得靠 fill-mode 收尾。想让动画在悬停时暂停,用 animation-play-state: paused,比删掉 animation 平滑得多。「动画卡顿」几乎总能归结到同一件事:你动的属性把浏览器逼回了渲染管线的更早一步。理解这条管线,就理解了为什么所有性能建议最后都收敛成一句「只动 transform 和 opacity」。
渲染管线的三步
- 布局(layout / reflow):算出每个元素的位置和尺寸。改动几何属性会触发它,而且一个元素的尺寸变化可能连累它的兄弟和祖先重新算——代价最大;
- 绘制(paint):把每个元素画成像素(填色、描边、画阴影)。改颜色类属性会触发它,不用重算位置,但仍要重画一片区域;
- 合成(composite):把已经画好的图层拼到屏幕上。这一步只是搬运和变形,最便宜;
- 关键在于:触发布局必然导致后面两步也重来;触发绘制会导致合成重来;而只触发合成的改动,前两步的结果可以直接复用——甚至能交给合成线程处理,主线程被 JS 占满时动画依然流畅。
常见属性触发到哪一步
| 改动的属性 | 最早触发 | 说明 |
|---|---|---|
width / height / margin / padding / top / left / font-size | 布局 | 几何变了,位置尺寸要重算,随后还要重绘、重合成 |
color / background-color / box-shadow / border-radius / visibility | 绘制 | 位置没变,但像素要重画 |
transform / opacity | 合成 | 已画好的内容直接变形 / 调透明度,可跑在合成线程 |
所以「让方块向右移动」有两种写法:改 left 是每一帧都重新布局,改 transform: translateX() 是每一帧只做一次合成——视觉效果一样,成本差着量级。
怎么把动画改写成合成友好的
- 位移:
left/top/margin→transform: translate(); - 尺寸变化:
width/height→transform: scale()(注意 scale 会把文字和边框一起缩放,视觉上不完全等价,边框可能变糊); - 显隐:
display切换 →opacity过渡(配合前面讲的allow-discrete或延迟设visibility); - 展开折叠:
height: auto→transform: scaleY(),或接受一次布局代价、只在交互开始时算一次。
will-change 是提示,不是魔法
will-change: transform告诉浏览器「这个元素待会要变」,浏览器可能据此提前把它提升为独立图层,省掉动画开始那一刻的准备开销;- 但提升图层是有内存代价的:每个图层都要占显存,给几十上百个元素一律加
will-change反而会让页面更卡甚至更耗电; - 正确用法:只给确实即将动、且数量很少的元素加,动画结束后把它移除;能在交互前一刻用 JS 加上最好;
- 它解决不了「你在动布局属性」这个根本问题——先把属性换对,再考虑要不要提示。
/* ❌ 动 left:每帧重新布局 */
.bad {
position: absolute; left: 0;
transition: left .3s;
}
.bad.move { left: 200px; }
/* ✅ 动 transform:只走合成,可跑在合成线程 */
.good { transition: transform .3s ease-out; }
.good.move { transform: translateX(200px); }
/* ❌ 动 width → ✅ 动 scale(注意文字会一起缩放) */
.grow { transition: transform .2s; }
.grow:hover { transform: scale(1.04); }
/* 淡入淡出用 opacity,不要切 display */
.fade { opacity: 0; transition: opacity .2s; }
.fade.show { opacity: 1; }
/* will-change:只给少量、确实要动的元素;用完就撤 */
.drawer.is-animating { will-change: transform; }
/* ❌ 别写:* { will-change: transform; } —— 图层爆炸、吃显存 */will-change 直接写死在 CSS 里、并且作用于很多元素,是最常见的反向优化——它会一直占着图层内存,而不是「只在动画期间」。另一个坑:transform: scale() 缩放的是已经绘制好的位图,放大较多时文字和 1px 边框会显得模糊,这是合成的固有代价,不是写错了。最后,上面表格里的分类是通行规律,具体元素的实际表现还受层叠上下文、滤镜、是否已被提升为图层等影响——真要优化请以 Performance 面板的数字为准。transform。DevTools 的 Performance 面板能直接看出每帧花在 Layout / Paint / Composite 上的时间,卡顿时按这个顺序查最快(01 章讲过怎么打开 DevTools)。这不是给设计加的客套条款。对前庭功能障碍人群来说,大幅位移、视差滚动、缩放和旋转动画会真实引发眩晕、恶心、偏头痛——有人因此不得不关掉整个网站的动画,甚至无法使用。prefers-reduced-motion 就是他们在系统设置里表达这个需求的方式。
这个偏好从哪来
- 主流桌面和移动操作系统都在无障碍设置里提供了「减少动态效果」开关;
- 用户打开它之后,浏览器让
@media (prefers-reduced-motion: reduce)匹配成功; - 它不是「不喜欢动画」的审美表达,而是一个明确的健康诉求——用户特意去系统设置里打开了它,我们没有理由忽略。
怎么写:收短,而不是简单粗暴地 none
- 直觉写法是全局
animation: none; transition: none,但这会带来一个实际故障:不少交互代码依赖transitionend/animationend事件来做收尾(移除元素、解锁按钮、切换状态)。过渡被设成none后这些事件不再触发,功能就卡死了; - 更稳妥的做法是把时长收成一个极短值(例如
0.01ms)而不是none:视觉上等同于瞬间完成,但事件仍会照常触发; - 同时把
animation-iteration-count设为1,避免无限循环的动画一直在那转; scroll-behavior: auto也该一起关掉平滑滚动——大幅度的自动滚动同样是眩晕来源。
更好的做法:保留不引起眩晕的动效
- 粗暴全局收短是兜底,理想做法是分层处理:真正有害的是大幅位移、视差、旋转、缩放,而纯
opacity的淡入淡出通常是安全的; - 所以更体贴的写法是:在 reduce 下把「滑入」降级成「淡入」,把视差改成静止背景,把自动轮播改成手动切换——保留状态变化的可感知性,去掉位移;
- 反过来还可以用
@media (prefers-reduced-motion: no-preference)做渐进增强:默认写静态样式,只在用户没有表达减少偏好时才加上动画。这个方向更安全,因为默认态就是无动画的; - 另外,自动播放的循环动画(无限旋转、跳动、闪烁)本身就要克制——闪烁类效果还涉及光敏性癫痫风险,无论有没有这个偏好都不该做高频闪烁。
/* 兜底:收短而非关掉,transitionend 仍会触发 */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
/* 更体贴:位移降级成淡入,保留可感知性 */
.toast { transition: opacity .2s, transform .2s; }
@media (prefers-reduced-motion: reduce) {
.toast { transform: none !important; } /* 只去掉位移,仍然淡入 */
}
/* 渐进增强方向:默认静态,没有减少偏好时才加动画 */
@media (prefers-reduced-motion: no-preference) {
html { scroll-behavior: smooth; }
.card { animation: fade-up .4s ease-out both; }
}
/* JS 动画不受 CSS 约束,必须自己判断 */
// const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
// el.scrollIntoView({ behavior: reduce ? 'auto' : 'smooth' });transition: none !important 会让依赖 transitionend 的 JS 永远等不到回调,表现为弹窗关不掉、按钮一直处于 loading——所以要用 0.01ms 而不是 none。另一个常被漏掉的:JS 驱动的动画(requestAnimationFrame、动画库、平滑滚动)不受 CSS 媒体查询约束,必须在脚本里用 matchMedia('(prefers-reduced-motion: reduce)') 自己判断。还有 scroll-behavior: smooth 和 <video autoplay> 的循环背景视频,也都在需要降级的范围内。「顶部阅读进度条」「元素滚进视口时淡入」这两件事的传统做法是监听 scroll 事件或者上 IntersectionObserver。现在它们是把动画的时间轴从「时间」换成「滚动位置」——一行 animation-timeline。而且动画跑在合成线程上,主线程再忙也不掉帧。
两种时间轴,分工很清楚
animation-timeline: scroll()——滚动进度时间轴:进度 = 滚动容器已滚距离 ÷ 可滚总距离。用来做进度条、视差;animation-timeline: view()——元素可见性时间轴:进度 = 这个元素相对视口的位置。用来做进场动画;- 写
@keyframes时照常用from/to或百分比,只是这些百分比不再表示时间,而是表示滚动到哪了。animation-duration被忽略(写1s只是占位),但animation-fill-mode: both一般要写,否则区间外没有样式。
进度是严格线性的
- 一个
scrollHeight 1200 / clientHeight 200的滚动容器(可滚 1000px),进度条动画@keyframes grow { from { width: 0 } to { width: 400px } }; - 滚到 0% / 25% / 50% / 100% 时读到的宽度分别是 0px / 100px / 200px / 400px——和滚动比例严格一一对应;
- 一个细节值得知道:这个动画的
currentTime读出来不是毫秒而是百分比("25%"、"100%"),timeline的类型是ScrollTimeline。拿现成的 JS 动画代码来改,凡是按 ms 算时间的地方都要改。
animation-range:动画在哪一段跑
view()配animation-range: entry 0% cover 40%的意思是「从元素刚露头开始,到它盖住视口 40% 时结束」;- (容器高 200px、目标块高 50px):目标块距容器顶 408px 时
opacity是起始值 0.1,滚到距顶 158px 时 0.478,距顶 8px 时已经到 1——正好在还没完全进入视口时就淡入完成,这就是range的作用; - 可用的区间关键字:
cover(完整可见周期)、contain、entry、exit、entry-crossing、exit-crossing。调这个比调 keyframes 有效得多。
三个必须知道的边界
scroll()默认找最近的滚动祖先(等价scroll(nearest block))。页面级进度条要写scroll(root block),否则一旦被放进任何一个带overflow: auto的容器里就悄悄换了参照物;- 时间轴是由祖先或自己命名的:
scroll-timeline: --t block定义在滚动容器上,只有它的后代能引用。要跨出这个子树就必须用timeline-scope: --t把名字提升到共同祖先上——「明明写了名字却没反应」几乎都是这一条; - 它天生尊重
prefers-reduced-motion吗?不。滚动驱动动画同样是动效,前一卡那条媒体查询要照样包上——尤其是位移和缩放类。
/* ① 页面顶部阅读进度条:整段就这些 */
.progress {
position: fixed; inset: 0 0 auto; height: 3px;
transform-origin: left;
background: var(--color-primary);
animation: grow 1s linear both; /* 1s 只是占位,会被忽略 */
animation-timeline: scroll(root block); /* 明确写 root,别靠默认 */
}
@keyframes grow { from { scale: 0 1; } to { scale: 1 1; } }
/* ② 滚进视口时淡入上浮 */
.reveal {
animation: fade-up 1s linear both;
animation-timeline: view();
animation-range: entry 0% entry 100%; /* 露头到完全进入 */
}
@keyframes fade-up {
from { opacity: 0; translate: 0 2rem; }
to { opacity: 1; translate: 0 0; }
}
/* ③ 跨子树引用:名字要先提升到共同祖先 */
.page { timeline-scope: --main-scroll; }
.scroller { overflow-y: auto; scroll-timeline: --main-scroll block; }
.indicator { animation-timeline: --main-scroll; }
/* ④ 减少动态效果时退回静态 */
@media (prefers-reduced-motion: reduce) {
.reveal { animation: none; opacity: 1; translate: none; }
}view() 类动画常以 opacity: 0 起手,一旦时间轴没解析成功(名字写错、被放进别的滚动容器、浏览器不支持),动画不跑,元素永远停在 opacity: 0。排查顺序是先在 DevTools 里读该元素的计算 animation-timeline,再看 Animations 面板里有没有这条动画。顺带一处:
overflow: hidden 的容器不是滚动容器,scroll(nearest) 会跳过它继续往上找——但 overflow: auto 且内容没超出时它也不产生可滚距离,动画进度恒为 0,看起来同样像「没生效」。第三个:别用它做「必须发生」的事。滚动驱动动画的进度完全由用户控制,用户不滚就永远停在起点;任何「读到这里才显示的内容」都不能只靠它。
第四个:
animation-duration 在这里对进度毫无影响——把它写成 5s,滚到 50% 时读到的宽度仍是终值的一半(100px / 200px),一点没变慢;但 getAnimations()[0].effect.getTiming().duration 照样如实返回 5000,所以别靠读它判断动画是不是滚动驱动的(要看 timeline 是不是 ScrollTimeline)。想让动画「跑得快一点」应该缩短 animation-range 的区间。animation-fill-mode: both(或简写里的 both),并且让「最终状态」等于「不写任何动画时的样子」。这样在不支持滚动时间轴的浏览器里,动画会被当成一段普通的 1s 动画立刻跑完,用户看到的是正常的页面而不是一片空白——这是这类特性最重要的降级设计。反过来,如果起始帧是 opacity: 0 而没有 both,不支持的浏览器上内容可能永远看不见,这是最糟糕的失败方式。列表切换成详情、缩略图放大成大图、换个筛选条件重排卡片——这些「前后两个状态之间」的动画,传统做法要同时把新旧两份 DOM 留在页面上、手算坐标做 FLIP。视图过渡把这件事变成:你照常改 DOM,浏览器负责补上中间那段动画。
它到底做了什么
- 调
document.startViewTransition(() => { 改 DOM })。浏览器先给当前页面拍一张快照,然后执行你的回调,再拍一张,最后在两张快照之间做交叉淡入并平移缩放; - 要让某个元素「自己动」而不是跟着整页淡入,给它
view-transition-name: hero——新旧状态里同名的元素会被配对,浏览器据此算出位移与缩放; - 动画期间浏览器在
<html>上挂一棵伪元素树:::view-transition-group(name)(负责位移缩放)、::view-transition-old(name)/::view-transition-new(name)(两张快照)。想改时长、缓动、单独换动画,就是给这些伪元素写普通 CSS; - (Chrome 150):过渡进行中读
::view-transition-group(hero)与::view-transition-old(hero)的尺寸都是 50px × 50px(元素的原始尺寸),同一时刻document.getAnimations()有 13 条动画,其中能看到::view-transition-group(hero)、group(root)、old(root)、new(root)——「root」就是整页那一组,所以不写任何 name 也会有一次整页淡入。
三个返回的 Promise,各管一段
transition.updateCallbackDone——你的回调(含await的数据请求)跑完了;transition.ready——快照拍好、伪元素树建好、动画即将开始。要用 JS 接管动画就在这里(document.documentElement.animate(…, { pseudoElement: "::view-transition-new(hero)" }));transition.finished——动画结束、伪元素树拆掉。
出来的两条重要行为
- 同一时刻两个元素叫同一个 name,整个过渡会被中止。
transition.ready抛InvalidStateError: Transition was aborted because of invalid state——但updateCallbackDone照样完成,DOM 改动全部落地。也就是说:页面功能正常,只是动画没了。这正是它难查的原因(症状:「大部分时候有动画,某些数据下就没有」,多半是列表里两行拿到了同一个 name); - 前一个过渡还没结束就再起一个,旧的会被跳过——而
finished仍然 resolve。旧过渡的ready以AbortError拒绝,finished却正常 resolve。所以想知道「我这次过渡有没有真的播出来」,要 catch 的是ready而不是finished; - 必须给 Promise 挂 catch。上面两种情况都会产生 rejected promise,不处理就是控制台里一片未捕获错误。
跨页面版本:两行 CSS,不用 SPA
- 同源的多页应用(普通的
<a>跳转)只要两个页面都写@view-transition { navigation: auto; },导航时就会有过渡——不需要任何框架、不需要 JS; - 两个页面上同名的
view-transition-name会配对,于是「列表页的缩略图平滑放大成详情页的大图」在纯静态站点上也能做到; - 细粒度控制用
view-transition-class(一批元素共用一套动画规则)与@media (prefers-reduced-motion)里整段关掉。
// ① 最小用法:照常改 DOM,动画白拿
if (document.startViewTransition) {
const t = document.startViewTransition(() => render(nextState));
t.ready.catch(() => {}); // 被中止时别让它变成未捕获错误
} else {
render(nextState); // 降级:没有动画,功能照常
}
/* ② 让某个元素自己动:新旧页面同名即配对 */
.hero-img { view-transition-name: hero; }
/* ⚠️ 列表里必须逐项唯一,否则整个过渡被中止 */
// el.style.viewTransitionName = "card-" + item.id;
/* ③ 调动画就是给伪元素写 CSS */
::view-transition-group(hero) { animation-duration: .35s; }
::view-transition-old(root) { animation: .2s both fade-out; }
::view-transition-new(root) { animation: .3s both fade-in; }
/* ④ 跨页面:两个页面都写这一段就够了 */
@view-transition { navigation: auto; }
/* ⑤ 尊重系统设置 */
@media (prefers-reduced-motion: reduce) {
::view-transition-group(*) { animation: none; }
}startViewTransition 的回调返回 Promise 时,浏览器会一直冻住页面等它(快照已经拍好、界面是静止的一张图)。在回调里去发请求,用户看到的就是整页卡住不动——数据要在调用之前拿到,回调里只做同步的 DOM 更新。第二个:快照是位图。过渡期间旧状态那一层是一张静态图片,里面的视频会停、动画会冻、文字不能选;过渡时长因此不该超过约 300–400ms。
第三个:
view-transition-name 会创建层叠上下文(确认,属于 07 章那份清单里的一员)——给某个容器加上它,容器内部的 z-index 从此跳不出去,原本能盖住外面的浮层会被关进来。但它不形成 fixed 的包含块(容器内的 position: fixed; left: 0 仍然贴在视口左边,x 为 0,与 contain: paint 那种真会改基准的属性不同)。这两条要分开记,别一起怪到它头上。第四个:跨页面版本要求同源、且两个页面都声明
@view-transition。只在其中一个页面写,效果是没有过渡,而不是报错。view-transition-name 最好在需要的那一刻用 JS 设、动画结束就删(或者只给「当前这一项」设)。原因有两个:一是同名冲突会中止整个过渡,二是带 name 的元素会被单独提升成一层,几十个元素同时带 name 只会拖慢过渡。常见写法是点击某张卡片时给它 el.style.viewTransitionName = "hero",transition.finished 之后清空——「只有正在参与过渡的元素才有名字」。响应式设计
响应式不是「最后加几个断点」的事后补救,而是从一开始就承认:页面多宽、用什么设备指点、喜欢深色还是浅色,都不由你决定。这一章从移动优先的写法约定讲起,穿过视口单位在移动端的经典陷阱、clamp() 的流式排版,落到容器查询这个真正让组件可复用的能力,最后收口成一张「响应式到底有几个维度」的全景。
移动优先不是「先做手机版」的项目排期,而是一条 CSS 写法约定:默认样式(不带任何媒体查询的那部分)描述最窄的屏幕,然后用 min-width 一级级往上加。这个方向和层叠的方向、和渐进增强的思路都是同向的,写起来也确实更省事。
为什么方向是「窄 → 宽」而不是反过来
- 窄屏的样式天然更简单:单列、块级堆叠、不需要多列网格。把这份最简单的样式当基线,最容易写对;
- 反过来写(默认宽屏 +
max-width往下砍)意味着每个断点都要把上一级的布局撤销掉——撤销比叠加难得多,也更容易漏掉某条属性; - 降级场景(媒体查询不生效、CSS 只加载了一半)下,用户拿到的是那份「单列、能读」的基线,而不是被撑爆的桌面布局。
断点定在哪:看内容,不看机型
- 常见起点值是
40rem/48rem/64rem/80rem(约 640 / 768 / 1024 / 1280 CSS 像素),Tailwind 的sm/md/lg/xl也在这一带(见 14 章);但它们只是起点。 - 真正的做法是把窗口从窄拖到宽,盯着某个组件,看它什么时候开始难看:正文行长超过约 70 个字符、卡片被挤成一条、导航换行错位——断点就定在那里。
- 所以每个项目的断点数量和位置都不一样,而且不必全站统一:不同组件完全可以有各自的断点。按机型命名(「iPad 断点」)是个陷阱,机型每年都在变。
现代范围语法
@media (width >= 48rem)与老写法(min-width: 48rem)等价,但读起来就是一个不等式;- 区间可以一次写完:
@media (40rem <= width < 64rem); - 老写法拼区间要写成
(min-width: 40rem) and (max-width: 63.999rem),那个「上界减一点点」是像素级 bug 的经典来源——写成64rem就会和下一档重叠,两档样式同时生效。
/* 移动优先:默认样式服务窄屏,逐级 min-width 增强 */
.grid {
display: grid;
gap: 1rem;
grid-template-columns: 1fr; /* 基线:单列,最简单的那份 */
}
/* 断点用 rem/em —— 跟随用户的浏览器默认字号 */
@media (min-width: 48rem) {
.grid { grid-template-columns: repeat(2, 1fr); }
}
@media (min-width: 64rem) {
.grid { grid-template-columns: repeat(3, 1fr); }
}
/* 现代范围语法:读起来就是不等式 */
@media (width >= 48rem) {
.nav { display: flex; }
}
/* 区间一次写完,不用再算「减一点点」的上界 */
@media (40rem <= width < 64rem) {
.sidebar { display: none; }
}px 时,用户把浏览器默认字号调大(无障碍设置里很常见),字变大了而断点纹丝不动,于是文字撑破布局却不换档。用 em/rem 更稳。但这里有一条必须记准的规则:规范规定媒体查询中的相对单位按初始值计算,所以 em 和 rem 在媒体查询里都等于浏览器默认字号,不受 html { font-size } 影响——这和它们在普通声明里的含义不一样。别以为把根字号设成 62.5%,断点就跟着缩小了;也别因为改了根字号去反向补偿断点值。(历史上 em 在这一点上的实现最一致,社区因此约定俗成写 em。)flex-wrap、minmax()、auto-fit)和 clamp() 把大部分尺寸变化吃掉,剩下真正需要「换一种布局」的地方才加断点。另外别指望把断点值收进自定义属性:媒体查询的条件里不能用 var(),要复用断点值只能靠预处理器变量或构建工具(Tailwind 的 --breakpoint-* 正是这么做的,见 15 章)。vw/vh 把视口宽高的 1% 当单位,看上去是「全屏」的完美答案。实际上它在移动端和带滚动条的桌面上各埋了一个经典易错点,而且两个都不会报错,只会让布局悄悄不对。
100vh 在移动端为什么会溢出
- 手机浏览器的地址栏和底部工具栏会随滚动收起、展开,可见区域的高度是变化的;
- 传统的
vh取的是「浏览器 UI 收起时」的那个大视口高度,于是页面刚打开、工具栏还占着位置的时候,100vh的区块比真正可见的区域更高——底部按钮被顶到屏幕外,这就是被抱怨了很多年的那个 bug; - 它不是某个浏览器的实现缺陷,而是「一个高度值」无法描述「一个会变的高度」,所以规范补了三套新单位。
三套新视口单位:small / large / dynamic
| 单位 | 参照哪个视口 | 会随滚动变化吗 | 什么时候用 |
|---|---|---|---|
svh / svw / svi / svb | small:浏览器 UI 全部展开时的最小视口 | 不会 | 要保证「无论如何都不溢出」 |
lvh / lvw | large:浏览器 UI 全部收起时的最大视口 | 不会 | 背景要铺满,被 UI 盖住一点无所谓 |
dvh / dvw | dynamic:当前实际视口,随 UI 伸缩实时变化 | 会 | 全屏区块、贴底元素 |
- 全屏首屏区块用
100dvh最贴合直觉;但如果不希望元素高度在用户滚动时不停变化(会连带触发内部重排,动画里尤其难看),100svh更稳当; - 老浏览器兜底很简单:先写
height: 100vh,再写height: 100dvh——不认识dvh的浏览器会丢掉后一行,认识的用后一行覆盖。
100vw 与滚动条:横向溢出的头号元凶
- 桌面上若页面有纵向滚动条、且滚动条是「占位」的传统样式,
vw参照的宽度里包含滚动条,于是100vw比内容可用宽度更宽,页面凭空多出一条横向滚动条; 100%参照的是包含块的宽度,天然不含滚动条——想让块级元素铺满父级,写width: 100%,或者干脆什么都不写(块级元素默认就撑满);- 确实需要「铺满视口宽度、突破父容器 padding」的通栏效果时(
margin-inline: calc(50% - 50vw)一类写法),同样要意识到它把滚动条宽度算进去了; scrollbar-gutter: stable可以让滚动条的空间常驻,减少「内容变长出现滚动条时页面横向跳一下」的抖动。
/* 全屏首屏:dvh 跟随地址栏伸缩,vh 作为老浏览器兜底 */
.hero {
min-height: 100vh; /* 兜底:不认识 dvh 的浏览器用这行 */
min-height: 100dvh; /* 现代浏览器覆盖上一行 */
}
/* 不希望高度随滚动抖动时,用 small viewport */
.side-panel { height: 100svh; }
/* 横向溢出的元凶:100vw 把滚动条宽度也算进去了 */
.full-bleed-bad { width: 100vw; } /* 桌面上可能撑出横向滚动条 */
.full-bleed-good { width: 100%; } /* 参照包含块,不含滚动条 */
/* 让滚动条空间常驻,避免「出现滚动条时页面横向跳一下」 */
html { scrollbar-gutter: stable; }100vh 做「一屏高的容器」再往里塞可滚动内容,移动端上高度对不齐是必然的。另一个更隐蔽的混淆:vh 参照的永远是视口,不是父元素——嵌套再深的元素写 height: 50vh 也还是半个屏幕高,和它父元素多高毫无关系;而 height: 50% 参照父元素,并且父元素高度不确定时这条声明会直接失效。两者混用是「高度怎么设都不生效」的常见起因。%,想相对「视口」才用 v* 单位;而只要涉及高度并且要跑在手机上,优先 dvh/svh 而不是 vh。全屏英雄区更稳妥的写法是 min-height: 100svh 而不是 height: 100vh——用 min-height 让内容超出时能自然撑高,而不是被裁掉一截。与其为字号和间距排布三四个断点让它们「跳变」,不如让它们随视口连续变化。clamp(最小值, 首选值, 最大值) 一行就够:首选值里放一个跟视口走的分量,两端用 rem 把上下限卡死。
clamp 到底在算什么
clamp(A, B, C)完全等价于max(A, min(B, C))——先用 C 封顶,再用 A 兜底,没有任何魔法;- 三个位置都能写混合运算,且
clamp/min/max内部本身就是计算上下文,不用再套一层calc(); - 典型写法
font-size: clamp(1.5rem, 1rem + 2vw, 3rem):1rem是「基座」,2vw是「随视口的增量」。基座越大、vw 系数越小,字号对视口就越不敏感。
为什么首选值必须混一个 rem,不能是纯 vw
- 用户放大页面时,CSS 像素本身变大、而视口的 CSS 像素宽度等比变小,两边正好抵消——于是纯
vw算出来的字号在物理尺寸上几乎纹丝不动:用户明明放大了,字却没变大; - WCAG 要求文本能放大到 200% 仍然可用,纯
vw字号会直接踩到这条,这是明确的无障碍缺陷而不是审美问题; - 首选值里带上
rem分量后,这一部分跟随根字号与缩放走,缩放就重新生效了。上下限同样用rem写,别用px; - 还要留意上限:如果放大后立刻顶到
max,缩放照样等于失效——上限要给足余量。
不只用于字号
- 间距:
padding-block: clamp(2rem, 6vw, 5rem),窄屏不浪费空间,宽屏不显空旷; - 宽度:
width: min(100% - 2rem, 70ch)一行同时表达「最多 70 个字符宽」和「窄屏两侧各留 1rem」,比max-width+margin两条声明更紧凑; - 网格轨道:
repeat(auto-fit, minmax(min(100%, 18rem), 1fr))——里面那个min()是为了在容器比18rem还窄时不溢出,是 auto-fit 网格的标准补丁。
/* 流式标题:1rem 基座保证缩放有效,2vw 提供视口敏感度 */
h1 { font-size: clamp(1.5rem, 1rem + 2vw, 3rem); }
/* 等价展开 —— clamp 只是这个式子的糖 */
h1 { font-size: max(1.5rem, min(1rem + 2vw, 3rem)); }
/* 流式间距:窄屏不浪费,宽屏不空旷 */
.section { padding-block: clamp(2rem, 6vw, 5rem); }
/* min():一行表达「最多 70 字符宽 + 窄屏两侧留白」 */
.prose {
width: min(100% - 2rem, 70ch);
margin-inline: auto;
}
/* 网格轨道里的 min():容器比 18rem 还窄时不溢出 */
.cards {
grid-template-columns: repeat(auto-fit, minmax(min(100%, 18rem), 1fr));
}
/* ✗ 反例:纯 vw,用户放大页面时字号几乎不变 */
h1 { font-size: 4vw; }font-size: 4vw,或 clamp() 的首选值只写 vw)会让浏览器缩放对文本失效,是明确的无障碍问题——首选值里一定要有 rem 分量。② 上下限写反不会报错:clamp() 里如果最小值大于最大值,结果取最小值(相当于 min 无条件胜出),页面上只表现为「这个值怎么调都不动」,查起来很费时间。③ clamp() 里做加减法,运算符两侧必须有空格(1rem + 2vw,不能写 1rem+2vw),否则整条声明被判无效。clamp(),那会让整套排版尺度失控、也难以在设计稿上对齐。稳妥顺序是:先定一套固定 rem 的字阶,只给「窄屏太大、宽屏太小」的极端项(通常是大标题和区块间距)加 clamp();正文字号保持固定,读起来才稳定。把首选值的斜率固化进令牌里,可以让全站的流式行为保持一致(见 12 章的设计令牌卡)。媒体查询问的是「窗口多宽」,容器查询问的是「装我的这个盒子多宽」。同一个卡片组件,放进窄侧栏时竖排、放进宽主区时横排,由组件自己决定——这是媒体查询做不到的事,也是组件真正可复用的前提。
两步:先声明容器,再查询
- 在某个祖先元素上写
container-type: inline-size,它才成为可被查询的容器; - 然后
@container (min-width: 25rem) { … }里的条件,问的是最近的那个祖先容器的内联尺寸; - 想指名道姓查某个容器,给它
container-name: card,查询写成@container card (min-width: 25rem)。简写container: card / inline-size一行设两个属性; - 范围语法同样可用:
@container (width >= 25rem)。
container-type 的副作用要先认下来
inline-size会在该元素上建立 inline-size containment(同时还有 layout 与 style containment):它的内联尺寸不再由内容撑开,而是完全由外部布局决定;- 对一个普通块级
div无所谓(它本来就撑满父级),但如果这个元素是inline-block、浮动元素、或者本来靠内容撑宽的网格/弹性子项,加上container-type后会突然塌成一条——这就是「加了容器查询布局就崩」的真相; container-type: size是两个方向都做 containment,要求高度也由外部确定,否则高度塌成 0。绝大多数时候你要的是inline-size;- 实践上的稳妥做法:不要把
container-type加在组件根元素上,而是外面包一层专门的容器div,让组件根元素保持干净。
容器查询单位
cqw/cqh是容器宽 / 高的 1%,cqi/cqb是容器内联 / 块方向尺寸的 1%,另有cqmin/cqmax;- 写组件内部的流式排版时
cqi往往比vw更贴切:字号跟着「组件有多宽」变,而不是跟着「窗口有多宽」变——组件搬个位置也不会突然字巨大; - 这些单位参照的同样是最近的祖先容器,脱离容器就无从谈起;
- 此外还有查询自定义属性的 style() 容器查询(
@container style(--variant: compact)),能力更新一些,用前请确认目标浏览器的支持情况。
/* ① 外面包一层专门做容器,组件根元素保持干净 */
.card-wrap {
container-type: inline-size;
container-name: card;
/* 简写: container: card / inline-size; */
}
/* ② 按容器宽度切形态 —— 和窗口多宽完全无关 */
.card { display: grid; gap: 1rem; }
@container card (width >= 25rem) {
.card {
grid-template-columns: 8rem 1fr; /* 宽容器:图文左右排 */
align-items: start;
}
}
/* ③ 容器查询单位:字号跟着「组件多宽」走,而不是视口 */
.card h3 { font-size: clamp(1rem, 4cqi, 1.5rem); }
/* ✗ 常见错误:container-type 加在自己身上,又想查询自己 */
.card { container-type: inline-size; }
@container (width >= 25rem) {
.card { grid-template-columns: 8rem 1fr; } /* 不生效 */
}@container 里写 .card { … },而 .card 自己就是那个容器的话,规则不会按你想的生效——被选中的元素必须是容器的后代。② 如果向上找不到任何 container-type 不为 normal 的祖先,@container 规则整个不匹配,而且不会退化成「按视口算」,只是静悄悄什么都不发生——忘了写 container-type 是新手最常查半天的坑。③ 容器查询的条件里同样不能用 var()。「响应式」经常被窄化成「加媒体查询改宽度」,但真正要适配的至少有五六个维度:布局、资源、排版、组件形态、输入能力、用户偏好。而且其中最省力的那几个,根本不需要媒体查询。
各维度各有各的工具
| 维度 | 要适配什么 | 首选手段 | 要写断点吗 |
|---|---|---|---|
| 布局 | 容器变宽变窄时的排列 | Grid 的 repeat(auto-fit, minmax(16rem, 1fr))、Flex 的 flex-wrap(见 08 章) | 不用 |
| 资源 | 不同屏幕该下多大的图 | srcset/sizes、<picture>(见 04 章) | 不用 |
| 排版 | 字号与间距的连续变化 | clamp()、min()/max() | 不用 |
| 组件形态 | 同一组件在不同宿主里的样子 | 容器查询 @container | 组件级 |
| 页面骨架 | 导航、整页栏数这类大切换 | 媒体查询 @media | 要 |
| 输入能力 | 触屏没有 hover、手指比鼠标粗 | (hover: hover)、(pointer: coarse) | 能力查询 |
| 用户偏好 | 深色、减少动效、对比度 | prefers-color-scheme、prefers-reduced-motion | 偏好查询 |
| 浏览器能力 | 新特性用不了时怎么退 | @supports 特性查询 | 渐进增强 |
能力查询:别把 hover 当理所当然
- 把
:hover效果关进@media (hover: hover),触屏设备就不会出现「点一下菜单弹出来、再也不消失」这个经典 bug; @media (pointer: coarse)表示主输入设备精度低(手指),此时把可点区域放大到手指够得着的尺寸;- 关键在于这两个查的是能力而不是尺寸:平板可以宽得像桌面却完全没有 hover,用宽度去猜输入方式一定会猜错。
偏好查询与 @supports
prefers-color-scheme: dark跟随系统深色(配色实现见 09 章);prefers-reduced-motion: reduce时把动效降到最低,这是无障碍硬要求而非可选项(见 10 章);还有prefers-contrast、forced-colors等按需再查;@supports (container-type: inline-size) { … }只在浏览器认这个属性时生效,反向写@supports not (…)提供降级路径;- 但要清楚
@supports检测的是「这条声明能被解析」,不代表实现完全正确——它是渐进增强的开关,不是万能的能力检测。
/* ① 无断点响应式网格 —— 容器多宽就放几列 */
.gallery {
display: grid;
gap: 1rem;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 16rem), 1fr));
}
/* ② 能力查询:只有真能 hover 的设备才写 hover 效果 */
@media (hover: hover) {
.btn:hover { background: var(--color-primary-hover); }
}
@media (pointer: coarse) {
.btn { min-block-size: 2.75rem; } /* 手指够得着 */
}
/* ③ 偏好查询:尊重「减少动态效果」 */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
/* ④ 特性查询:认识容器查询才用,否则退回媒体查询 */
@supports (container-type: inline-size) {
.card-wrap { container-type: inline-size; }
}prefers-reduced-motion——它不等于「关掉所有动画」,粗暴地把过渡全删掉反而会让界面变得突兀、失去状态变化的线索;正确做法是把位移、缩放、视差这类前庭刺激大的动效换成极短的淡入淡出。CSS 架构与工程化
会写 CSS 和能维护一份几万行的样式表,是两件不同的事。这一章讲的全是「规模变大之后才开始痛」的东西:用设计令牌把值收拢、用 @layer 从根上管住优先级、用原生嵌套组织结构、用 @scope 与 Shadow DOM 划清边界,最后落到交付性能——样式写得再漂亮,把首屏渲染卡住了也是负分。
自定义属性的语言机制(会继承、var() 的回退、能被 JS 改)在 05 章讲过了;这一卡讲的是怎么用它搭出一套撑得住换肤和长期维护的令牌体系。核心只有一句:颜色值和使用场景之间必须隔一层。
三层令牌,各司其职
| 层 | 名字长什么样 | 谁在引用它 | 换主题时改哪层 |
|---|---|---|---|
| 原始层 primitive | --blue-500、--space-4、--radius-md | 只被语义层引用 | 一般不动 |
| 语义层 semantic | --color-primary、--color-surface、--color-text-muted | 组件样式直接用 | 只改这一层 |
| 组件层 component | --btn-bg、--card-padding | 单个组件内部,兼作对外旋钮 | 按需覆盖 |
- 关键在中间那层间接:组件写
background: var(--color-surface)而不是var(--gray-100),换主题时只要把--color-surface重新指向别的原始令牌,所有组件跟着变,一个组件的 CSS 都不用碰; - 反过来,如果组件里直接写了
--gray-100,「深色模式下 surface 应该是深灰」这个决策就散落进了几十个文件——换肤于是变成全局搜索替换; - 命名按用途而不是长相:
--color-danger比--color-red好,某天品牌把危险色改成橙色,名字都不用改。
作用域覆盖:令牌真正的杀手锏
- 自定义属性是会继承的普通属性,在任何元素上重新赋值,只影响这棵子树;
- 于是
.card--inverted { --color-surface: var(--gray-900); }就能把一张卡片整块变深,卡片内部所有引用了这个令牌的组件自动跟随,一行覆盖样式都不用写; - 组件层令牌的意义也在这里:组件对外暴露
--btn-bg这样的调节旋钮,使用者不必再和组件内部的选择器打特异性战争; - 切主题的标准做法就是在
html上挂[data-theme="dark"](或用prefers-color-scheme媒体查询),只在那里重写语义层。较新的light-dark()函数可以在一条声明里同时给出明暗两个值,但它要求元素或祖先上设了color-scheme: light dark才生效,用前请确认目标浏览器的支持情况。
令牌化的边界在哪
- 令牌不只有颜色:间距阶、圆角、字号阶、阴影、动效时长都该令牌化,它们同样会在设计评审里被整体调整;
- 但别把每个一次性的值都做成令牌——令牌的价值来自「被多处引用 + 会被整体调整」,只用一次的值做成令牌,只是给读代码的人多加了一层跳转;
- Tailwind v4 的
@theme本质就是这套东西的官方形态:在@theme里定义的自定义属性同时生成对应工具类,见 15 章。
/* ① 原始层:只是色板与尺度,不表达用途 */
:root {
--blue-500: oklch(0.55 0.18 260);
--gray-100: oklch(0.97 0 0);
--gray-900: oklch(0.21 0 0);
--space-3: 0.75rem;
}
/* ② 语义层:表达用途,组件只认这一层 */
:root {
--color-primary: var(--blue-500);
--color-surface: var(--gray-100);
--color-text: var(--gray-900);
}
/* 换主题 = 只重写语义层,组件 CSS 一行不用改 */
[data-theme="dark"] {
--color-surface: var(--gray-900);
--color-text: var(--gray-100);
}
/* ③ 组件层:对外暴露的「旋钮」,关键令牌给回退值 */
.btn {
--btn-bg: var(--color-primary, #3b5bdb);
background: var(--btn-bg);
padding: var(--space-3) calc(var(--space-3) * 2);
}
/* 作用域覆盖:只影响这棵子树,不写一行覆盖样式 */
.card--inverted { --color-surface: var(--gray-900); }var() 的失败行为:当 --x 未定义、且没写回退值时,整条声明会变成「计算值时无效」并按 unset 处理——结果既不是你想要的值,也不会回退到上一条规则的值(这一点和普通的语法错误完全不同,后者是整条声明被丢弃、上一条规则照常生效)。所以关键令牌一律写成 var(--color-primary, #3b5bdb)。另外自定义属性名大小写敏感,--Color 和 --color 是两个毫不相干的属性,而且拼错了不会有任何报错。--color-bg / --color-text / --color-border / --color-primary…),并用它们跑通深色模式——这一步就能消掉大部分重复,也会逼你把「这个灰到底是背景还是边框」想清楚。原始层等到色板需要成体系时再补。「样式覆盖不掉」是 CSS 最消耗人的日常。传统解法是堆选择器提权,最后演变成 .page .sidebar .widget a.link 这种没人敢删的军备竞赛。层叠层(@layer)换了个思路:先把优先级的粗粒度顺序定死,层内怎么写都不影响大局。
第一步:集中声明层顺序
- 在样式表最上面写一行
@layer reset, base, components, utilities;,只声明顺序、不写内容; - 此后无论各层的规则出现在文件的哪个位置、以什么顺序被加载,都按这个声明顺序排;
- 后声明的层胜出:
utilities里的.mt-0 { margin-top: 0 }能盖掉components里的.page .card h2,哪怕后者特异性高得多; - 层内才轮到特异性和源码顺序比大小。也就是说,特异性从「全局战场」降级成了「层内的局部规则」——这是
@layer全部价值的来源。层叠的完整比较步骤见 05 章。
三条反直觉但必须记准的规则
- ① 未分层的样式,优先级高于所有分层样式(就普通声明而言)。这是规范行为不是 bug:把老代码原样留在层外、新写的样式塞进层里,结果是新样式反而盖不住老代码。迁移时要么把老代码也导入某个低层,要么就接受它最大;
- ②
!important会把层顺序整个反过来:带!important的声明,越靠前的层反而越强,而未分层的!important在作者样式里最弱。这个设计是为了让「重置层」有能力用!important兜底,但也意味着别指望!important的旧直觉在分层世界里还成立; - ③ 行内
style属性排在层之前比较,任何分层样式都盖不掉它(除非动用!important)——这也是为什么「选择器写得再长也压不过行内样式」。
把第三方 CSS 关进低层
@import url("vendor.css") layer(vendor);一行就把整个第三方库塞进vendor层,从此你自己的任何样式都能盖住它,不用再去研究它的选择器写得多凶;- 注意
@import必须写在样式表最前面(只有@charset和@layer声明语句能排在它之前),位置写错整条@import会被静默丢弃; - 层可以嵌套(
@layer components.card { … });不写名字的匿名层@layer { … }每出现一次就是一个独立的新层,之后无法再被引用追加; - 配合
:where()::where(.btn)的特异性为 0(见 06 章),把重置和基础样式包进:where(),使用者随便一个类就能覆盖。@layer管跨层的强弱,:where()管层内的强弱,两者互补。
层顺序真的压倒选择器权重
@layer base, comp; /* 先声明顺序:base 弱,comp 强 */
@layer comp { .box { color: blue } } /* 特异性 0-1-0(极低) */
@layer base { #id.box.box2 { color: red } } /* 特异性 1-2-0(高得多) */
<div id="id" class="box box2">
Chrome getComputedStyle 得到:rgb(0, 0, 255) —— 蓝色赢- 一个 id 加两个类,输给了一个单类选择器——只因为它在更靠前(更弱)的层里。这是
@layer最反直觉也最有价值的性质:它把「谁能覆盖谁」从「谁的选择器写得更狠」改成了「架构上谁该更强」,于是不必再靠堆#id或!important打优先级战争。 - 推论也要记住:没分层的样式比所有层都强。所以第三方库如果没把自己包进层里,你在层里写的覆盖会失效——这时得用
@import url(lib.css) layer(vendor);把它强行塞进一个层。
/* ① 第一件事:集中声明层顺序(越靠后越强) */
@layer reset, base, components, utilities;
/* ② 第三方库直接关进最低层 —— @import 必须写在最前面 */
@import url("normalize.css") layer(reset);
/* ③ 层内特异性再高,也输给后面的层 */
@layer components {
.page .sidebar .card h2 { margin-block-start: 2rem; } /* (0,3,1) */
}
@layer utilities {
.mt-0 { margin-block-start: 0; } /* (0,1,0),但它赢 */
}
/* ④ 反直觉:没写进任何层的规则,优先级高于所有层 */
.card h2 { margin-block-start: 4rem; } /* 这条最终胜出 */
/* ⑤ :where() 把特异性压成 0,管层内的相对强弱 */
@layer base {
:where(a) { color: var(--color-primary); } /* (0,0,0) 随便覆盖 */
}
/* ⑥ 嵌套层与匿名层 */
@layer components.card { .card { padding: 1rem; } }
@layer { .one-off { color: red; } } /* 匿名层,之后追加不了 */@layer 声明语句,层的顺序就是「每个层第一次出现的先后」——某个文件的引入顺序一变,优先级跟着变,现象是「本地好好的,打包后样式就乱了」,极难排查。所以那行声明必须存在,且必须排在所有样式之前。另外要提醒的是:@layer 不会削弱 !important,它只是反转了 !important 之间的层序——想靠分层来「治好」满屏的 !important 是不现实的,得先把它们删掉。@layer reset, base, tokens, layouts, components, utilities;。判断某段样式该进哪层,就问一句「它应该被谁覆盖」——越基础、越该被覆盖的越靠前。落地上最见效的一步不是全量重构,而是先把第三方库和历史遗留 CSS 用 layer() 导入到最前面的层:几乎不动业务代码,就能解决一大半「盖不掉」的问题。用 Tailwind 的话还有一条直接推论:它的产物开头就是
@layer theme, base, components, utilities;,所有工具类都在层里——而未分层样式赢过一切分层样式,所以你随手写在层外的一条普通 CSS,天然盖得过任何工具类。这既解释了「为什么我的自定义样式莫名其妙赢了」,也是需要覆盖工具类时最省事的办法。嵌套曾是「必须上 Sass」的头号理由,如今浏览器原生就有。但它把好处和毛病一并继承了过来:嵌套写深了,Sass 时代的选择器爆炸和特异性失控会原样复现,只是这次没有编译器可以怪。
& 到底代表什么
&引用的是父规则的整个选择器列表,而不是逐个展开的某一条;- 由此推出一条经常被忽略的规则:
&的特异性等于:is(父选择器列表)的特异性,也就是列表里最具体的那一个。写.btn, #hero a { &:hover { … } }时,&会带上#hero a那一档的特异性,连.btn:hover这一支也被抬高——这正是「明明只写了个类,却怎么都覆盖不掉」的来源; &能出现在选择器的任何位置,包括写在后面:.sidebar & { … }表示「在 .sidebar 里的我」,对应 Sass 时代&前置的同款用法。
后代还是复合:一个符号的差别
.a { .b { … } }展开后是.a .b(后代,中间有空格),不是.a.b;- 想表达「同一个元素同时有这两个类」必须写
&.b; - 伪类同理,而且更容易出错:嵌套规则默认是相对选择器、隐含了后代组合符,所以直接写
:hover { … }意思是.a *:hover(后代里被 hover 的元素)。要作用在自己身上必须写&:hover——伪类前面的&不是风格偏好,是语义; - 早期规范要求嵌套的选择器不能以元素名开头(必须以
&或符号打头),后来放宽了,现在article { h2 { … } }可以直接这么写。
嵌套里还能放什么
- 媒体查询、容器查询、
@supports都能直接嵌进规则里:.card { @media (width >= 48rem) { padding: 2rem; } }——样式和它的生效条件待在一起,比在文件底部另开一个@media大块好维护得多; - 嵌套不引入新的作用域,它只是选择器的语法糖,不影响层叠、继承和特异性的计算规则(除了上面说的
&那条); - 它和
@layer、:where()可以叠着用:把组件包在层里、把基础样式包在:where()里,既有组织性又不牺牲可覆盖性。
/* & 引用父规则的整个选择器列表 */
.card {
padding: 1rem;
border-radius: 0.75rem; /* 声明写在嵌套规则之前更保险 */
& h2 { font-size: 1.25rem; } /* 后代 */
h2 + p { color: gray; } /* 现在可以直接以元素名开头 */
&:hover { box-shadow: 0 2px 8px rgb(0 0 0 / 0.1); }
&.is-selected { outline: 2px solid var(--color-primary); }
&[data-state="loading"] { opacity: 0.6; }
.sidebar & { padding: 0.5rem; } /* & 也能写在后面 */
@media (width >= 48rem) { /* 条件和样式待在一起 */
padding: 2rem;
}
}
/* ⚠ 差一个 & 就是两回事 */
.a { .b { } } /* → .a .b 后代 */
.a { &.b { } } /* → .a.b 同一元素同时有两个类 */
.a { :hover { } } /* → .a *:hover 后代里被 hover 的 */
.a { &:hover { } } /* → .a:hover 自己被 hover */.app .page .card .header .title,特异性高到只剩 !important 能覆盖——Sass 时代的老病,原生嵌套一点没治,只是让你更容易犯。② 早期实现里,写在嵌套规则之后的普通声明会被丢弃;规范后来补了修正让它按源码顺序生效,但为保险起见,习惯上把所有声明写在嵌套规则之前。③ 最常见的日常事故仍然是 .a { .b {} } 是后代不是复合,以及漏掉伪类前的 &——两者都不报错,只是选中了另一批元素。& 表达状态与变体(&:hover、&[aria-expanded="true"]、&.is-active),而不是用嵌套去还原 DOM 结构。想还原结构的冲动几乎总是错的——那等于把 HTML 的层级焊死进 CSS,DOM 一改样式全崩,而且选择器会越长越具体,最后只能靠 !important 破局。CSS 的原罪是只有一个全局命名空间:任何一条 .title 规则,都可能击中项目里任何一个 .title。整部 CSS 工程化史,某种意义上就是一部「给样式划边界」的历史。
解法谱系:五种划边界的方式
| 方案 | 怎么隔离 | 隔离强度 | 构建依赖 | 调试友好度 |
|---|---|---|---|---|
| BEM 等命名约定 | 靠人约定 .block__elem--mod 不重名 | 弱(全靠自觉) | 无 | 好,类名即文档 |
| CSS Modules | 编译期把类名改写成唯一串 | 强(不会撞名) | 要(Vite/webpack) | 中,DevTools 里是哈希名 |
| CSS-in-JS | 运行时生成样式与类名 | 强 | 要,且有运行时开销 | 中,类名随机 |
| Shadow DOM | 浏览器级的硬边界 | 最强(外部选择器进不去) | 无(原生) | 中,要钻进影子树里翻 |
@scope | 原生限定作用域的上下界 | 中(限定 DOM 子树) | 无(原生) | 好,选择器仍然可读 |
- 规律很清楚:隔离强度越高,跨组件复用和主题化就越费劲——Shadow DOM 硬到连全局字体重置都进不去,反倒要专门开口子放行;
- 所以选型不是「越隔离越好」,而是问一句:这块样式泄漏出去的代价有多大。
CSS Modules:编译期改名,最主流的折中
- 写标准 CSS 放进
*.module.css,构建工具把.btn改写成.Button_btn_x7f2这类唯一类名,组件里import后通过对象引用; - 它只解决类名冲突,不改变层叠与特异性规则——两条 CSS Modules 里的样式打架,仍然按特异性算;
:global(.foo)显式声明「这个类不要改名」,composes可以组合其它类;- 依赖构建工具,纯
.html里用不了。它和 Tailwind 的工具优先是两条不同路线(对比见 15 章)。
@scope:原生作用域,能同时定上下界
@scope (.card) { img { … } }里的img只匹配.card子树内的图片,:scope指作用域根元素本身;- 真正独特的是下界:
@scope (.card) to (.card-body)表示「从.card开始,但在.card-body处停下」——这正好解决「父组件的样式泄漏进嵌套的子组件」这个后代选择器无法表达的问题,也是其它几种方案都不覆盖的能力; - 层叠里还多了一条「作用域邻近度」的比较:同等条件下,作用域根离元素更近的规则胜出;
- 这是较新的能力,写生产代码前请确认目标浏览器的支持情况,必要时用
@supports兜底或先用别的方案。
/* Button.module.css —— 写标准 CSS,类名会被编译成唯一串 */
.btn { padding: .5rem 1rem; border-radius: .5rem; }
:global(.no-hash) { color: red; } /* 显式声明「别改名」 */
// Button.jsx —— import 后用对象引用,别硬编码字符串
import s from "./Button.module.css";
// <button className={s.btn}> → 实际类名 .Button_btn_x7f2
/* @scope:原生作用域,上界 .card,下界 .card-body */
@scope (.card) to (.card-body) {
img { border-radius: .5rem; } /* 只管 .card-body 之外的图 */
:scope { border: 1px solid var(--color-border); } /* :scope = .card */
}querySelector(".btn"),也别指望在全局样式表里写 .btn 能命中它——只能从导入的对象里取。② 更常见的是「明明用了 Modules 还是泄漏了」:它只改写类名,不改写元素选择器——*.module.css 里写一条 h2 { … } 仍然是全局的所有 h2,除非把它嵌套在某个局部类底下。③ @scope 的下界元素本身也在作用域之外,别按「包含下界」去理解边界。scoped、Svelte 的组件样式),别自己造轮子;要跨技术栈分发的设计系统 → 考虑 Shadow DOM;纯静态站点或渐进式改造 → BEM 约定 + @layer 就够,成本远低于引入一整条构建链。而 @scope 最适合的位置不是全面替代前几种,而是补上「限定下界」这个前面都做不到的能力。这一页从头到尾把「组件」当作前提在用,但组件在浏览器原生层面到底是什么?答案是三件套:<template> 提供可复用的 DOM 模板、自定义元素提供标签与生命周期、Shadow DOM 提供一道真正的样式与结构边界。
三件套,各解决一件事
<template>:里面的内容不渲染、不请求资源,要用 JS 克隆出来才生效——组件的 HTML 模板;- 自定义元素:
customElements.define("my-card", class extends HTMLElement { … }),名字必须带连字符(这是与内置标签划清界限的方式);connectedCallback等生命周期钩子在元素进出文档时触发; - Shadow DOM:
this.attachShadow({ mode: "open" })挂一棵影子树,组件的内部结构和样式都住在里面,<slot>用来接收使用者传进来的内容; - 这三者可以拆开单用:只用
<template>做模板、或者定义自定义元素但不开影子树,都完全合法。
Shadow DOM 的边界到底有多硬
- 选择器进不去也出不来:外部样式表里的
.title匹配不到影子树内的.title,影子树里的.title也影响不到外面。这是浏览器级的隔离,不是命名约定; - 但可继承属性能穿过去:
color、font-family、line-height这些会正常从宿主继承进影子树——所以全局的字体设置通常还是生效的; - 自定义属性也能穿:
--color-primary作为可继承的普通属性会一路传进影子树,这正是给 Web Components 做主题的标准手段; - 一句话总结:隔离的是「选择器的可达性」,不是「值的流动」。抓住这个区分,Shadow DOM 的样式问题基本都能自己推出来。
组件作者主动开的三个口子
| 口子 | 谁写 | 作用 |
|---|---|---|
:host / :host(.sel) | 组件作者 | 在影子树内部给宿主元素本身设样式;带参形式表示「宿主带某个类/属性时」 |
::slotted(sel) | 组件作者 | 给通过 <slot> 塞进来的外部节点设样式(只能命中最外层节点) |
::part(name) | 使用者 | 组件在内部元素上写 part="name",外部即可用 my-card::part(name) 定制 |
- 没有标
part的元素,外部就是碰不到——所以组件作者必须主动规划哪些内部结构对外开放,这是一次实实在在的 API 设计,和挑几个自定义属性当旋钮是同一件事。
那为什么大多数项目还是用框架组件
- 生态与状态管理:Web Components 只解决「封装一段 UI」,没有解决数据流、状态、路由、服务端渲染——而这些正是应用开发的主体工作量;
- 属性天然只能传字符串(复杂数据要走 property 赋值或事件),SSR 与水合长期是短板,表单集成和无障碍细节也得自己补;
- 框架的组件模型则和它的状态管理、构建、类型系统长在一起,开发体验的差距很直接;
- 选型判据:设计系统要被多个技术栈的团队消费(React 项目和 Vue 项目要用同一套按钮)→ Web Components 值得,它是唯一的跨框架公约数;只有单一框架的应用 → 用框架自己的组件更顺,别为了「原生」平添复杂度。
<!-- ① 模板:不渲染、不请求资源,等着被克隆 -->
<template id="card-tpl">
<style>
:host { display: block; border: 1px solid var(--color-border, #ddd); }
:host([compact]) { padding: .5rem; }
.title { font-size: 1.25rem; } /* 外部选择器打不进来 */
</style>
<h2 class="title" part="title"><slot name="title"></slot></h2>
<slot></slot>
</template>
// ② 自定义元素:名字必须带连字符,否则注册失败
customElements.define("my-card", class extends HTMLElement {
connectedCallback() {
const tpl = document.getElementById("card-tpl");
this.attachShadow({ mode: "open" })
.appendChild(tpl.content.cloneNode(true));
}
});
/* ③ 外部只能通过组件开放的口子定制 */
my-card::part(title) { color: rebeccapurple; } /* 走 part 开的口 */
my-card { --color-border: #333; } /* 自定义属性能穿透 */
my-card .title { color: red; } /* ✗ 无效:进不去影子树 */* { box-sizing: border-box } 进不去,影子树内部必须自己再写一遍(:host, *, *::before, *::after { box-sizing: border-box })。② 自定义元素名不带连字符会直接注册失败,而且报错信息未必显眼。③ 如果定义组件的脚本在元素已经出现在 HTML 之后才加载,元素会先以「未升级」的空壳状态渲染一瞬,内容闪一下——需要用 :not(:defined) 配合 visibility 之类做占位。@scope 或 CSS Modules 的成本低得多,别为了隔离样式就上 Shadow DOM,它带来的定制成本会一直跟着你。样式写得再优雅,如果它把首屏渲染卡住了,用户看到的就是一片白。这一卡讲的是 CSS 与资源在交付环节的几个决定性细节——它们不是微优化,而是「看得见」和「看不见」的差别。
CSS 是渲染阻塞资源
- 浏览器必须拿到并解析完 CSSOM 才能开始渲染,所以
<head>里每一个<link rel="stylesheet">都在推迟首次绘制; - 对策是关键 CSS 内联:把首屏可见部分需要的那点样式直接写进
<style>,其余样式异步加载(用rel="preload" as="style",加载完再切成 stylesheet); media属性可以让样式表不阻塞渲染:带media="print"这类当前不匹配的样式表仍然会下载,但不阻塞渲染;- 别把整站 CSS 打成一个巨包再全量内联——内联的部分无法被缓存,超过一定体积就得不偿失。
<head>的整体配置见 02 章。
字体:最容易让文字消失的一环
- 默认行为下,字体没下载完时浏览器会先藏起文字;
font-display: swap改成先用后备字体显示、下载完再换,用户至少能立刻读到内容; - 想让换字时的跳动更小,可以在
@font-face里用size-adjust、ascent-override这类度量覆盖属性去对齐后备字体; preload只给真正的首屏字体,而且<link rel="preload" as="font">上的crossorigin属性即使同源也必须写——字体是按 CORS 模式请求的,漏了会导致同一个文件被下载两次;- 预加载一堆用不上的字重,等于从首屏图片和 CSS 嘴里抢带宽,是标准的负优化。
图片:CLS 与 LCP 的两条硬规矩
- 每张图都要能在加载前算出占位空间:写上
width/height属性(浏览器据此得出宽高比预留位置),或者用aspect-ratio。不写就会出现「图片一到、内容整体往下跳」的布局偏移(CLS); - 首屏的大图千万别写
loading="lazy":懒加载要等布局算出它进入视口才开始请求,对首屏最大的那张图来说是纯粹的延迟,直接拖慢 LCP。首屏主图反而应该加fetchpriority="high"; loading="lazy"的正确用户是屏幕外的图片;再配合srcset/sizes别让手机去下桌面尺寸的图(见 04 章)。
两个进阶手段
content-visibility: auto让浏览器跳过屏幕外元素的渲染工作,长列表和长文档收益明显;必须配contain-intrinsic-size给一个占位尺寸,否则滚动条长度会随滚动不断变化。这是较新的能力,当渐进增强用;- 避免强制同步布局:在同一帧里「写样式 → 立刻读
offsetHeight/getBoundingClientRect()/getComputedStyle()」会逼浏览器提前算一次布局,放进滚动或动画回调里就是逐帧卡顿。做法是把读和写分批:先把需要的值全读完,再统一写; - 动画本身优先用
transform/opacity走合成层,见 10 章的合成层卡。
<!-- 关键 CSS 内联,其余样式异步加载 -->
<style>/* 首屏可见部分需要的那点样式 */</style>
<link rel="preload" href="/rest.css" as="style"
onload="this.rel='stylesheet'">
<!-- 字体:只 preload 真正的首屏字体,crossorigin 必写 -->
<link rel="preload" href="/inter.woff2" as="font"
type="font/woff2" crossorigin>
<!-- 首屏主图:不懒加载、抬优先级、写死尺寸防跳动 -->
<img src="/hero.avif" width="1200" height="630"
fetchpriority="high" alt="产品截图">
<!-- 屏幕外的图才懒加载 -->
<img src="/footer.avif" width="800" height="600"
loading="lazy" decoding="async" alt="">
/* ---------- 对应的 CSS ---------- */
@font-face {
font-family: "Inter";
src: url("/inter.woff2") format("woff2");
font-display: swap; /* 先用后备字体,别让文字消失 */
}
/* 没有宽高属性时,用 aspect-ratio 预留空间防 CLS */
.thumb { aspect-ratio: 16 / 9; width: 100%; height: auto; }
/* 长列表:跳过屏外渲染,必须给占位尺寸 */
.post-item {
content-visibility: auto;
contain-intrinsic-size: auto 12rem;
}loading="lazy"——很多脚手架和 CMS 默认这么干,结果首屏主图被推迟,LCP 直接恶化。② 第二常见的是把 preload 当成「加速一切」的银弹:它会抬高优先级并抢占带宽,预加载的东西越多,真正关键的资源反而越慢。③ content-visibility: auto 用在需要 JS 测量内部尺寸的容器上会出问题:被跳过渲染的子树处于 containment 状态,量出来的尺寸不是真实值;而且不配 contain-intrinsic-size 的话,滚动条长度会随滚动一路变化,用户体验很怪。font-display: swap。关键 CSS 内联和 content-visibility 属于收益明确但需要测量的档次——先用浏览器 DevTools 的性能面板看瓶颈到底在哪再动手。这条纪律和写 C++ 时「先 profile 再优化」是同一条:凭感觉优化,通常优化的是不要紧的地方。这一章前面几卡讲的是「怎么把样式管住」。这一卡讲一件正在发生的变化:过去必须由预处理器(Sass 的 mixin、函数、条件)或者 JS 完成的计算,正在被搬进 CSS 本身。它们都很新,但都已经能在最新的 Chrome 里跑通——本卡每条都注明了结果。
@function:真正的自定义函数
- 语法是
@function --name(--参数) { result: … },用起来和内置函数一样:width: --double(30px); - (Chrome 150):
@function --double(--n) { result: calc(var(--n) * 2) }配width: --double(30px),计算值是 60px; - 它和「用
var()+calc()凑出来的伪函数」的区别是有真正的参数和局部作用域——不再需要「先设一个变量、再引用一个用到它的变量」这种两步走的套路。
if():条件表达式
- 形如
color: if(style(--mode: dark): red; else: blue)——按自定义属性的值选取不同结果,还能用media()和supports()作条件; --mode: dark时取到rgb(255, 0, 0),--mode: light时取到rgb(0, 0, 255),两条都精确命中;- 它替掉的是那个人人写过的 hack:用
var(--flag, fallback)加空格开关模拟布尔值。有了if(),「一个变量控制一组样式」终于是可读的写法。
sibling-index() 与 contrast-color()
sibling-index()返回元素在兄弟中的序号,sibling-count()返回总数。三个兄弟写padding-left: calc(sibling-index() * 10px)得到 10px / 20px / 30px——过去这必须靠:nth-child(n)手写一串规则,或者由模板引擎生成内联style;- 典型用途:错开的进场延迟(
animation-delay: calc(sibling-index() * 60ms))、阶梯式缩进、按序号取色; contrast-color()让浏览器替你选黑还是白。contrast-color(rgb(240,240,240))得到rgb(0, 0, 0),contrast-color(rgb(20,20,20))得到rgb(255, 255, 255)——主题色由用户配置时,前景色不用再算一遍对比度。
用之前先想清楚三件事
- 这些是渐进增强,不是地基。凡是影响可读性的地方(正文颜色、布局尺寸)都要有不依赖它们的默认值,然后用
@supports或if(supports(…))叠加; @property仍然是最该先掌握的那一个(本章前面讲设计令牌时用过):给自定义属性声明类型后,它才能被过渡和动画插值——不声明<color>类型的颜色变量,改它时是瞬间跳变而不是渐变。这条不新,却是最常被漏掉的;- 别用它们替掉构建期能做的事。设计令牌、主题切换、响应式断点这些「构建期就能算清楚」的东西,写成静态 CSS 更快也更好调;
if()与@function的价值在运行期才知道的值(用户设置、容器尺寸、当前主题)。
/* ① 自定义函数:有参数、有局部作用域 */
@function --space(--n) {
result: calc(var(--n) * var(--space-unit, .25rem));
}
.card { padding: --space(4); gap: --space(2); }
/* ② 条件表达式:一个变量控制一组样式 */
.badge {
--bg: if(style(--tone: danger): var(--color-danger); else: var(--color-muted));
background: var(--bg);
color: contrast-color(var(--bg)); /* 前景黑白让浏览器按底色选 */
}
/* ③ 序号:错开的进场延迟 */
.list > li {
animation: fade-in .3s both;
animation-delay: calc(sibling-index() * 60ms);
}
/* ④ 老老实实的降级:默认值先写好 */
.badge { background: var(--color-muted); } /* 谁都能用 */
@supports (background: if(style(--x: 1): red; else: blue)) {
/* 支持才叠加上面那套 */
}
/* ⑤ 这条最该先学:声明类型,变量才能动画 */
@property --brand {
syntax: "<color>";
inherits: true;
initial-value: oklch(.62 .21 260);
}
.theme-switch { transition: --brand .3s; } /* 不声明就是瞬间跳变 */@supports 叠加」,而不是只写新写法。另一个容易翻车的地方:
@property 的 initial-value 对非 * 的 syntax 是必填的,漏了这一行注册就失败——等价的 JS 接口 CSS.registerProperty({ syntax: "<color>", inherits: true }) 直接抛 SyntaxError;而写在样式表里的那条 @property 虽然仍留在 cssRules 里,那个变量已经退回成未注册的普通自定义属性(var(--c2, 兜底色) 取到的是兜底色),于是既不能被过渡插值、也没有任何报错,只表现为「动画没生效」。第三个:本卡所有都是 Chrome 150 上跑的,这几条特性在其它浏览器的状态各不相同,落地前必须当场查兼容表,别把本页的数字当成跨浏览器结论。
sibling-index() 不支持时只是延迟全都变 0(动画一起出现,可以接受);contrast-color() 不支持时前景色回落到继承值(可能对比度不够,需要写默认值兜底);而 @function 算的如果是布局尺寸,不支持就是整块布局垮掉——同一批新特性,能不能现在用取决于你把它用在哪一层,不取决于它本身有多新。Tailwind 核心
Tailwind 是工具优先(utility-first)的 CSS 框架:把样式拆成单一职责的原子类,直接组合在标签上。本章先把「为什么这样做」讲透(以及它的代价),再讲 v4 的 CSS-first 安装方式、刻度系统与变体机制。本章内容基于 Tailwind v4。
工具优先常被误解成「为了少写 CSS」——恰恰相反,样式一行没少,只是从 .css 文件搬到了 class 属性里。它真正换来的是三样东西:约束、局部性、不用起名。理解这三点,才知道什么项目适合它、什么项目不适合。
① 约束:你只能从设计系统里挑
写原生 CSS 时,padding: 13px、color: #3b7dd8 这种随手值没有任何东西拦你,一个团队写三个月就会攒出几十种相近的灰色和十几种间距。Tailwind 里 p-4、text-blue-500 都来自一张有限的刻度表——想写随手值必须显式用任意值语法 p-[13px],这个「显式」本身就是一道刹车。设计一致性从「靠自觉」变成了「默认如此」。
② 局部性:改样式不用担心波及别处
原生 CSS 最消耗心智的时刻,是你想改 .card-title 却不确定还有谁在用它——于是你不敢改,只敢新增,CSS 只增不减。工具类写在元素上,改这里就只影响这里,删掉这个组件样式跟着一起没了。这也是为什么 Tailwind 项目的 CSS 体积不随项目年龄线性增长。
③ 不用起名:省掉 CSS 里最贵的一件事
「这个包裹层叫 .card-inner 还是 .card-body」——命名是写 CSS 里最耗时又最没有产出的决策,BEM 之类的方法论存在的意义大半就是替你做这个决定。工具类把它整个取消了。
代价:这三样不是白拿的
- HTML 变吵。一个稍复杂的元素十几个类是常态,读结构时噪声明显增加。这是真实成本,不必替它辩解。
- 离不开工具链。它必须经过构建才产出 CSS;没有编辑器插件(补全 + 悬停看真实 CSS)时上手体验会差很多。
- 复用要靠组件,不靠 class。同一串类出现三次,正确做法是抽成一个 React/Vue 组件或模板 partial,而不是抽成一个 CSS 类——后者会把上面②③两条好处直接还回去(详见 15 章的
@apply一卡)。
<!-- 传统:起类名 + 另写 CSS -->
<div class="card">...</div>
/* .card { display:flex; padding:1rem; border-radius:.5rem } */
<!-- Tailwind:原子类直接组合,零自定义 CSS -->
<div class="flex items-center gap-4 p-4 rounded-lg
bg-white shadow-md">
<img class="size-12 rounded-full" src="a.jpg">
<span class="text-lg font-semibold">Alice</span>
</div>items-center 不生效的原因和 align-items: center 不生效的原因是同一个。Tailwind 换掉的是书写方式,不是 CSS 本身;不懂 07 和 08 那两章的原理,遇到问题一样只能瞎试。v4 最大的改变是配置从 JavaScript 搬回了 CSS:不再有 tailwind.config.js,主题写在 CSS 的 @theme 块里;@import "tailwindcss"; 一行取代 v3 的三条 @tailwind 指令;扫描哪些文件也不用你写了。网上绝大多数教程还停在 v3,照抄会直接不生效,所以这一卡先把差异摆清楚。
v3 → v4 的四处关键变化
| 事项 | v3 写法 | v4 写法 |
|---|---|---|
| 引入 | @tailwind base; / components; / utilities; 三条 | @import "tailwindcss"; 一行 |
| 主题配置 | tailwind.config.js 的 theme.extend | CSS 里的 @theme { --color-brand: … } |
| 扫描范围 | 手写 content: [...] 数组 | 自动检测(尊重 .gitignore),需要时用 @source 增补 |
| 插件 | config 里 plugins: [require(...)] | CSS 里 @plugin "@tailwindcss/typography"; |
三种接入方式,按项目挑
- Vite 项目(含 Astro / SvelteKit / Nuxt 等):装
@tailwindcss/vite插件,最快、增量编译最好,首选。 - 非 Vite 的构建(Webpack / Rails 等):走 PostCSS 插件
@tailwindcss/postcss。 - 完全没有构建工具:
npx @tailwindcss/cli -i app.css -o out.css --watch,产出一个静态 CSS 文件直接<link>进去——想在 01 章那种单文件页面里试用 Tailwind,用这个最省事。
/* app.css —— 这一行就够了 */
@import "tailwindcss";
// vite.config.ts —— 推荐用官方插件
import tailwindcss from "@tailwindcss/vite";
export default { plugins: [tailwindcss()] };
# 或用 CLI(无构建工具时)
npx @tailwindcss/cli -i app.css -o out.css --watch@tailwind base;、或者建一个 tailwind.config.js 然后奇怪「为什么改了没反应」——这是 v4 下最高频的求助。v4 默认不读 tailwind.config.js,那个文件躺在项目里也只是个死文件。另一个高频坑是「类明明写了却没生成」:检查它所在的文件是不是被 .gitignore 排除了(自动检测会跳过被忽略的文件),是的话用 @source "…" 显式加回来。把「照 v3 写会怎样」这件事说准了:不是全都不生效,而是「一部分好使、一部分静默失踪」。
输入只有 @tailwind utilities; 各要一个类: flex (纯静态,不引用任何令牌)→ 生成了 p-4 (引用 --spacing) → 静默不生成 bg-white (引用 --color-white) → 静默不生成 text-lg (引用 --text-lg) → 静默不生成 产物共 93 字节,只有一条 .flex。 换成 @import "tailwindcss"; 同样三个类 → 4651 字节, 含 --spacing 定义与整套 preflight。机制是:v4 的工具类由
@theme 里的 CSS 变量派生,缺了变量层,凡引用令牌的类都无从生成,而纯静态的类照常出现。这比「全都不生效」难查得多——你会以为 Tailwind 装好了,只是「有些类不支持」,然后去查那些类的拼写。npx @tailwindcss/upgrade,它会把 config.js 迁成 @theme、把 @tailwind 换成 @import、并自动改掉被重命名的类(例如 bg-gradient-to-r → bg-linear-to-r、shadow-sm 一档的整体位移)。手工迁移几乎必漏第三类。背类名是最低效的学法。工具类名几乎都是「属性缩写 + 刻度值」的机械拼接,把两套刻度(间距、颜色)和一张属性对照表记住,剩下的基本能猜出来。
间距刻度:一个乘法
p-4 在 v4 下生成的不是一个固定值,而是一次计算(Tailwind 4.3.2 编译器直接编):
.p-4 { padding: calc(var(--spacing) * 4); }
/* --spacing: 0.25rem ← @theme 里的默认令牌 */v3 直接吐 padding: 1rem,两者差别很实际:整套间距刻度由一个变量派生,改 --spacing 一处就整体缩放,甚至能在运行时用 CSS 变量切换(v3 做不到——值在构建期就写死了)。v4 的间距不再是一张写死的表,而是一个基数:主题里 --spacing: 0.25rem,所有间距类都编译成 calc(var(--spacing) * N)。所以 p-4 = 1rem、gap-2 = 0.5rem、mt-8 = 2rem——记住「4 就是 1rem」,其余心算。因为是算出来的,mt-21、w-103 这种非常规值也直接可用,不必回头改配置。
颜色色阶:50 最浅,950 最深
每个色相 11 档(50 / 100…900 / 950)。经验对应:50–100 做背景底色、200–300 做边框和分隔线、500–600 做主色(按钮、链接)、700–900 做正文与标题。透明度用斜杠叠加:bg-black/50、text-white/70。v4 的默认调色板改用 oklch(),同一档在不同色相间的视觉亮度更接近,配色时更好换色。
CSS 属性 → 工具类前缀(反查表)
已经知道要写什么 CSS、只是不知道类名叫什么时,查这张表:
| CSS 属性 | 工具类前缀 | 例 |
|---|---|---|
margin / padding | m- mx- mt- / p- px- pt- | mt-4 px-6 |
gap | gap- gap-x- gap-y- | gap-4 |
width / height | w- / h- / size-(同时设两者) | size-12 max-w-md |
display | 直接是值:block flex grid inline-flex hidden | flex |
position | 直接是值:relative absolute fixed sticky | sticky top-0 |
color | text- | text-gray-700 |
background-color | bg- | bg-blue-500 |
font-size | text-(同前缀,见下方 pitfall) | text-lg |
font-weight | font- | font-semibold |
line-height / letter-spacing | leading- / tracking- | leading-relaxed |
border-width / border-radius | border- / rounded- | border-2 rounded-lg |
box-shadow | shadow- | shadow-md |
z-index / opacity | z- / opacity- | z-50 opacity-75 |
overflow | overflow- | overflow-x-auto |
<!-- 间距:p/m/gap,数字 × 0.25rem -->
class="p-4 px-6 mt-8 gap-2" <!-- 1rem / 1.5rem / 2rem -->
<!-- 尺寸 -->
class="w-full h-dvh max-w-md size-12"
<!-- 颜色:色名-深浅(50~950)-->
class="bg-blue-500 text-white border-gray-200"
<!-- 排版 -->
class="text-lg font-semibold leading-relaxed tracking-tight"text- 是一个前缀两种用途:text-lg 设字号、text-blue-500 设颜色,Tailwind 靠后面跟的是尺寸令牌还是颜色令牌来区分。后果是它们互不冲突、可以同时写(text-lg text-blue-500 正常工作),但也意味着 tailwind-merge 这类工具必须懂这个区别才能正确去重(见 16 章)。同理 border-2(宽度)和 border-gray-200(颜色)也是同前缀不同维度。-mt-4、-z-10、-translate-x-1/2——不是 mt--4。这是新手第一天最容易卡住的语法细节,而经典的绝对定位居中 absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 正好把它用上了。变体是写在类名前面、用冒号连接的条件前缀:hover:bg-blue-600 的意思是「这条声明只在 hover 时生效」。它不是什么特殊魔法——变体做的事就是给生成的选择器加个后缀,或者把规则包进一层 at-rule。想通这一点,所有变体的行为都能推出来。
变体的本质:加选择器,或包一层
hover:→ 选择器变成.hover\:x:hover;disabled:→:disabled;first:→:first-child。这类是加伪类。md:→ 规则被包进@media (width >= 48rem) { … };print:→@media print;supports-[…]:→@supports。这类是包 at-rule。group-hover:/peer-checked:是加祖先或兄弟条件(详见 16 章)。
可组合、可叠加
变体能一路串下去:md:hover:focus-within:bg-blue-50 表示四个条件同时满足。书写顺序不影响最终结果(媒体查询和伪类的交集与顺序无关),但社区惯例是从外到内:响应式 → 主题 → 状态,即 md:dark:hover:…。装上 prettier-plugin-tailwindcss 会自动帮你排成官方顺序,团队里就不必争这个。
常用状态变体速记
- 交互:
hover:focus:focus-visible:active:; - 表单:
disabled:checked:required:invalid:placeholder:; - 结构:
first:last:odd:even:; - 取反:
not-hover:(v4 新增,对应:not(:hover))。
<button class="bg-blue-500 px-4 py-2 rounded
hover:bg-blue-600 focus:ring-2 active:scale-95
disabled:opacity-50
dark:bg-blue-400
md:px-6 lg:px-8">提交</button>
<!-- group:父 hover 时改子元素 -->
<a class="group">
<span class="group-hover:translate-x-1">→</span>
</a>md:flex 的意思是「宽度 ≥ 48rem 时 flex」,也就是中屏及以上全都生效,而不是「只在中屏」。不带前缀的类是所有尺寸的基础值,前缀只做向上覆盖——所以正确写法永远是「先写小屏样式,再用前缀往大屏加」。要「只在小屏」得用 max-md:。另一处更隐蔽:v4 里
hover: 生成的规则被包在 @media (hover: hover) 内,纯触摸设备上不会触发。这通常是好事(避免手机上点一下样式黏住),但如果你把关键信息藏在 hover: 后面,手机用户就永远看不到了。focus-visible: 而不是 focus: 做可见焦点环:focus: 在鼠标点击时也会亮,很多人嫌丑就干脆 outline-none 全关掉,键盘用户从此失去焦点指示。focus-visible:ring-2 只在浏览器判断「需要给键盘用户提示」时出现,两边都满意(无障碍要求见 04 章)。动效的原理(过渡 vs 关键帧、该动哪些属性、为什么只动 transform/opacity)在 10 章讲过,这一卡只讲它们怎么映射成工具类。
过渡的四个旋钮
- 动什么:
transition(一组常见可动画属性)、transition-colors、transition-transform、transition-opacity、transition-all、transition-none; - 多久:
duration-200(单位毫秒,也可duration-[350ms]); - 怎么走:
ease-linear/ease-in/ease-out/ease-in-out; - 等多久再开始:
delay-100。
过渡类写在元素本身,变化值写在变体里:transition duration-200 hover:-translate-y-0.5。写反了(把 transition 放进 hover:)会导致移入有动画、移出瞬间跳回。
内置动画与自定义动画
- 开箱四个:
animate-spin(加载圈)、animate-pulse(骨架屏)、animate-bounce、animate-ping(涟漪提示);animate-none关闭。 - 自定义走
@theme的--animate-*命名空间:定义--animate-wiggle: wiggle 1s ease-in-out infinite;,@keyframes wiggle照常写在 CSS 里,之后就有了animate-wiggle类。
<!-- 过渡:hover 时平滑上移 + 变色 -->
<button class="transition duration-200 ease-out
hover:-translate-y-0.5 hover:bg-blue-600">悬停我</button>
<!-- 内置动画:加载圈 / 骨架屏占位 -->
<svg class="animate-spin size-5">...</svg>
<div class="animate-pulse h-4 rounded bg-gray-200"></div>
/* app.css —— 自定义动画:@theme 里注册,@keyframes 照常写 */
@theme {
--animate-wiggle: wiggle 1s ease-in-out infinite;
}
@keyframes wiggle {
0%, 100% { transform: rotate(-3deg); }
50% { transform: rotate( 3deg); }
}
<!-- 之后即可 class="animate-wiggle" -->
<!-- 尊重「减少动态效果」偏好 -->
<div class="animate-bounce motion-reduce:animate-none"></div>transition 和 transition-all 不是一回事。裸 transition 只覆盖一份精选清单(颜色系、opacity、box-shadow、transform/translate/scale/rotate、滤镜等);transition-all 是字面意义的 transition-property: all,会连 width、height、top 这些触发重排的属性一起动,容易掉帧,还常导致「改个不相干的类整个元素莫名飘一下」。默认用 transition,需要精确控制就写 transition-transform 这类具名的,尽量别用 transition-all。motion-reduce:transition-none / motion-reduce:animate-none 尊重系统的「减少动态效果」偏好——这不是锦上添花,对前庭功能障碍用户是实打实的无障碍要求(原理见 10 章)。反过来还有 motion-safe:,只在用户没开该偏好时才加动效,两种写法选一种保持全项目统一即可。Tailwind 布局
把 Flexbox、Grid、定位和断点映射成工具类,布局直接在标签上完成。原理不在这里重讲——08 和 11 两章已经讲过,这一章只回答「那些属性在 Tailwind 里叫什么、有哪些额外的坑」,外加 v4 内置的容器查询。
Flex 与 Grid 的原理(主轴交叉轴、flex 简写的三个分量、隐式网格、fr 与 minmax())见 08 章。这一卡只做映射:那些属性在 Tailwind 里叫什么。
Flexbox 映射
| CSS | 工具类 |
|---|---|
display: flex | flex(行内版 inline-flex) |
flex-direction | flex-row flex-col flex-row-reverse |
justify-content(主轴) | justify-start justify-center justify-between justify-around |
align-items(交叉轴) | items-start items-center items-stretch items-baseline |
flex: 1 / flex-shrink: 0 | flex-1 / shrink-0(还有 grow basis-*) |
flex-wrap | flex-wrap flex-nowrap |
Grid 映射
| CSS | 工具类 |
|---|---|
display: grid | grid |
grid-template-columns: repeat(3, minmax(0,1fr)) | grid-cols-3 |
| 任意轨道定义 | grid-cols-[200px_1fr_auto](空格写成下划线) |
grid-column: span 2 | col-span-2(整行 col-span-full) |
grid-auto-flow: dense | grid-flow-row-dense |
gap / 单轴 | gap-4 / gap-x-4 gap-y-2 |
三个高频陷阱
justify-*和items-*谁管哪个轴,取决于flex-col。加了flex-col之后主轴变成竖直方向,justify-center管的就是垂直居中、items-center管水平——这是排查「居中居错方向」的第一反应。- flex 子项默认
min-width: auto,不会小于内容。所以长文本或overflow-hidden的子项会把容器撑破,标准解法是给该子项加min-w-0(Grid 里同理)。这条几乎是 Flex 布局撑破的唯一原因。 grid-cols-3生成的是minmax(0, 1fr)而不是1fr——Tailwind 已经替你踩掉了「1fr 轨道被内容撑大」的坑,这也是它比手写repeat(3, 1fr)更省心的地方。
<!-- Flexbox:横向两端对齐 + 垂直居中 -->
<nav class="flex items-center justify-between gap-4 p-4">
<div class="font-bold">Logo</div>
<div class="flex gap-2">...</div>
</nav>
<!-- Grid:3 列网格 -->
<div class="grid grid-cols-3 gap-4">
<div class="col-span-2">跨两列</div>
</div>flex、grid 这些 display 类必须加在容器上,而 gap-4、justify-* 也一样。最常见的失败是「gap-4 加了但没间距」——十有八九是忘了在同一个元素上加 flex 或 grid,普通块容器上 gap 无效(这种场景要用 space-y-*,取舍见 16 章)。flex items-center justify-center 和 grid place-items-center,后者少一个类,单个居中元素时更顺手。13 章讲了变体机制本身,这一卡专讲断点:默认值是多少、怎么写「只在小屏」、怎么改。响应式设计的方法论(为什么移动优先、断点该按内容还是按设备定)见 11 章。
五个默认断点
| 前缀 | min-width | 约等于 | 典型设备 |
|---|---|---|---|
sm: | 40rem | 640px | 大手机横屏 / 小平板 |
md: | 48rem | 768px | 平板竖屏 |
lg: | 64rem | 1024px | 平板横屏 / 小笔记本 |
xl: | 80rem | 1280px | 桌面 |
2xl: | 96rem | 1536px | 大屏桌面 |
断点用 rem 定义,会跟随用户在浏览器里设置的默认字号缩放——把默认字号调大的用户会更早进入「小屏」布局,这是有意为之的无障碍设计。
移动优先的写法惯例
无前缀的类是所有尺寸的基础,带前缀的类只做向上覆盖。所以正确顺序永远是「先写窄屏,再逐级加宽」:
- ✅
grid-cols-1 md:grid-cols-2 lg:grid-cols-4——窄屏单列,逐步变多; - ❌
grid-cols-4 md:grid-cols-1——能跑,但读起来是反的,后期加断点会越写越乱。
推论:sm: 不是「小屏专用」,它是「≥40rem」,手机竖屏反而不命中。想给手机单独写样式,用的是无前缀的类。
反向与区间:max-* 变体
max-md:hidden→@media (width < 48rem),即「小于 md 时隐藏」,用于少数确实只该出现在窄屏的东西(汉堡菜单按钮)。- 两个前缀叠加得到区间:
md:max-lg:flex= 「48rem ≤ 宽 < 64rem 时 flex」。 - 一次性断点不必进主题:
min-[900px]:flex/max-[420px]:text-sm直接内联。
自定义断点
v4 在 @theme 里用 --breakpoint-* 命名空间:写 --breakpoint-3xl: 120rem; 就多出一整套 3xl: 前缀;把 --breakpoint-sm 重新赋值即可改掉内置断点的数值。
<!-- 移动优先:1 列 → md 起 2 列 → lg 起 4 列 -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">...</div>
<!-- 小屏竖排,md 起横排 -->
<div class="flex flex-col md:flex-row gap-4">...</div>
<!-- 汉堡按钮只在窄屏出现;导航只在宽屏出现 -->
<button class="md:hidden">☰</button>
<nav class="hidden md:flex gap-6">...</nav>
<!-- 区间:仅 md ≤ 宽 < lg -->
<div class="md:max-lg:flex"></div>
/* app.css —— 自定义 / 覆盖断点 */
@theme {
--breakpoint-3xl: 120rem; /* 新增 3xl: 前缀 */
}lg:grid-cols-3 照样在大屏下变三列然后挤成一团——这是断点方案的结构性局限,不是你写错了。真正的解法是容器查询(下一卡)。另外:断点值改动后整套 md: 语义都变了,改内置断点前先确认全项目没人依赖旧值。hidden md:flex——先无条件隐藏,再在 md 起改成 flex。只写 md:flex 是不够的,窄屏下它仍然是默认的 display(<nav> 就是 block),照样显示。定位类几乎是 CSS 属性值的直译,难点不在类名而在定位本身的规则(包含块是谁、sticky 为什么不粘)——那些在 07 章。这一卡讲映射,外加 Tailwind 特有的任意值语法。
定位工具类
- 模式:
static/relative/absolute/fixed/sticky——直接就是属性值。 - 偏移:
top-0right-4-bottom-2;inset-0一次设四边(等价top/right/bottom/left: 0),inset-x-0/inset-y-0设一对。铺满父级的遮罩就是absolute inset-0。 - 层级:
z-0z-10…z-50,任意值z-[999]。 - 逻辑属性版:
start-0/end-0对应inset-inline-start/end,RTL 下自动翻转。
任意值:设计系统的逃生舱
方括号语法给任意工具类塞自定义值:top-[117px]、bg-[#1da1f2]、grid-cols-[1fr_2fr]、w-[min(90vw,640px)]、h-[calc(100dvh-4rem)]。值里不能有空格,用下划线 _ 代替,Tailwind 会还原。还能引用自己的 CSS 变量:bg-[var(--brand)]。任意值、任意属性、任意变体这三种逃生舱的完整用法与坑,见 16 章。
<!-- sticky 吸顶 + z 层级 -->
<header class="sticky top-0 z-50 backdrop-blur">...</header>
<!-- 铺满父级的遮罩:父级需 relative -->
<div class="relative">
<div class="absolute inset-0 bg-black/50"></div>
</div>
<!-- 绝对定位居中:注意负值把减号写在最前 -->
<div class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2"></div>
<!-- 任意值:跳出设计令牌;空格写成下划线 -->
<div class="top-[72px] w-[min(90vw,640px)] h-[calc(100dvh-4rem)]
grid-cols-[200px_1fr] bg-[var(--brand)]">z-50 不管用,八成是层叠上下文的问题,不是数字不够大。transform、filter、opacity 小于 1、backdrop-blur、will-change 都会创建新的层叠上下文——而 Tailwind 里这些恰好是超高频类。一旦某个祖先创建了层叠上下文,子元素的 z-index 就只能在这个上下文内部比较,写到 z-[9999] 也盖不过外面的兄弟。排查方法是往上找哪个祖先带了 transform/opacity-*/backdrop-*,而不是继续加零。h-screen(100vh)。移动端浏览器的地址栏会伸缩,100vh 长期偏大,页面底部被工具栏吃掉。用 h-dvh(动态视口高度)代替;需要「不随地址栏跳动」的稳定值时用 h-svh。这几个单位的完整区别见 11 章。断点问的是「视口多宽」,容器查询问的是「我的父容器多宽」。组件不再需要知道自己被放在哪里——这是真正的组件级响应式,v4 已内置,不用装插件(原理与浏览器支持见 11 章)。
两步就能用
- ① 在要被测量的父元素上加
@container; - ② 子元素用
@sm:/@md:/@lg:这类带 @ 的变体(注意和视口的sm:只差一个 @)。
容器断点是另一套刻度,比视口断点小得多,因为容器通常远窄于视口:
| 变体 | 容器宽度 | 变体 | 容器宽度 |
|---|---|---|---|
@3xs: | 16rem | @lg: | 32rem |
@2xs: | 18rem | @xl: | 36rem |
@xs: | 20rem | @2xl: | 42rem |
@sm: | 24rem | @3xl: | 48rem |
@md: | 28rem | @max-md: | 反向变体(另有 @min-[420px]: 任意值) |
命名容器:跨层级查询
嵌套多层容器时,@sm: 只认最近的那个祖先容器。要指定查哪一个,给容器起名 @container/side,变体写成 @lg/side:flex——语法和 group/name 一个路子。
顺带一提的两组现代工具类
- 逻辑属性:
ms-4(margin-inline-start)、pe-6、border-s、text-start——在 RTL 语言下自动左右翻转,做多语言站点时用它代替ml-/pr-/text-left。 - 3D 变换:v4 新增
perspective-*、rotate-x-*、rotate-y-*、transform-3d,翻卡片一类效果不用再写任意属性。
<!-- 容器查询:按父容器宽度,而非视口 -->
<div class="@container">
<!-- 容器 ≥24rem 两列,≥32rem 横排 -->
<div class="flex flex-col @sm:grid @sm:grid-cols-2 @lg:flex-row gap-4">
...
</div>
</div>
<!-- 命名容器:显式指定查哪一层 -->
<aside class="@container/side">
<div class="@container/card">
<p class="@lg/side:text-lg">看的是 side 那一层</p>
</div>
</aside>
<!-- 逻辑属性:ms=margin-inline-start,自动适配 RTL -->
<div class="ms-4 pe-6 border-s text-start">@sm: 是容器 ≥24rem(384px),而 sm: 是视口 ≥40rem(640px),差了一大截。另一个必踩的坑:
@container 会把元素变成尺寸包含(containment)上下文,元素不能再由内容决定被查询方向上的尺寸,否则就成了循环依赖。表现就是给一个宽度由内容撑开的元素加 @container 后布局塌掉——把 @container 加在有确定宽度来源的那一层(例如 grid 单元格、w-full 的包裹层)即可。@container 才能做到「放哪都对」,省掉一大堆传 size="compact" 之类的 prop。Tailwind 定制与工程
从「用得动」到「用得对」:在 CSS 里定义设计令牌、想清楚复用单位到底是组件还是 CSS 类、挑插件、以及把 Tailwind 放进整个 CSS 方案版图里比较。这一章决定的是项目跑到第二年时的可维护性。
v4 把主题从 JavaScript 搬进 CSS,换来一个关键性质:@theme 里定义的每个令牌同时是「工具类的来源」和「页面上真实可用的 CSS 自定义属性」。v3 时代 config 里的颜色只有 Tailwind 自己知道,写原生 CSS 或内联样式时得再抄一遍;v4 里它就是 :root 上的一个变量,两边共用一份真相。
命名空间决定生成哪些工具类
一条 @theme 声明能派生出整族工具类(Tailwind 4.3.2):
@theme { --color-brand: oklch(0.7 0.2 250); }
自动就有了:
.bg-brand { background-color: var(--color-brand); }
.text-brand { color: var(--color-brand); }
.border-brand { border-color: var(--color-brand); }注意生成的是 var(--color-brand) 而不是把颜色值内联进去——令牌在产物里始终以变量形式存在,所以同一份 CSS 能在运行时被改写(暗色模式、多主题都靠这一点,见 16 章)。前缀不是随便起的,它决定 Tailwind 拿这个令牌生成什么:
| 命名空间 | 生成的工具类 | 例 |
|---|---|---|
--color-* | bg-* text-* border-* fill-* ring-* … | --color-brand → bg-brand |
--font-* | font-* | --font-display → font-display |
--text-* | text-*(字号) | --text-hero → text-hero |
--breakpoint-* | 响应式变体前缀 | --breakpoint-3xl → 3xl: |
--animate-* | animate-* | --animate-wiggle → animate-wiggle |
--radius-* / --shadow-* | rounded-* / shadow-* | --radius-card → rounded-card |
--spacing(无 *) | 整套间距刻度的基数 | 默认 0.25rem,p-4 = calc(var(--spacing) * 4) |
两边共用一份真相
因为令牌是真变量,同一个品牌色可以:工具类里 bg-brand、原生 CSS 里 background: var(--color-brand)、内联样式里 style="color: var(--color-brand)"、甚至 JS 里 getComputedStyle 读出来。第三方组件只认 CSS 变量时,把主题令牌接上去即可,不用再维护第二份色板。
@theme inline:值引用了别的变量时用它
默认的 @theme 会把令牌输出到 :root,工具类里引用的是这个令牌变量本身。而 @theme inline 不输出该变量,直接把值内联进工具类。差别在什么时候要紧?当令牌的值本身是另一个会变的变量时——例如 --color-fg: var(--app-fg),而 --app-fg 在暗色模式下被改写。用普通 @theme 时中间多一层间接引用,某些嵌套场景下解析结果不是你想要的;用 @theme inline 则 text-fg 直接展开成 color: var(--app-fg),永远跟着最新值走。判据:令牌值是字面量就用普通 @theme;令牌值是 var(…) 且那个变量会随主题切换而变,就用 @theme inline。
@import "tailwindcss";
@theme {
--color-brand: oklch(0.55 0.18 280);
--color-brand-dark: oklch(0.45 0.18 280);
--font-display: "Satoshi", sans-serif;
--radius-card: 0.75rem;
--breakpoint-3xl: 120rem;
}
/* 值指向会随主题变化的变量 → 用 inline */
@theme inline {
--color-surface: var(--app-surface);
}
:root { --app-surface: white; }
[data-theme=dark] { --app-surface: oklch(0.21 0.01 265); }
<!-- 令牌自动生成工具类 -->
<div class="bg-brand rounded-card font-display 3xl:p-12">
<!-- 同一份令牌在原生 CSS / 内联样式里也能直接用 -->
<div style="box-shadow: 0 0 0 2px var(--color-brand)">--color-*: initial; 这种通配写法先清空整个命名空间再补自己的;--*: initial; 则清空所有默认主题。不清空就抱怨「产物里怎么还有一堆没用的颜色」是没意义的——不过要说明的是,v4 对令牌本身也做树摇——只有真正被用到的令牌才会写进 :root,没用上的默认色板根本不进产物。所以清空命名空间的意义不是减体积而是加约束:清掉 --color-* 之后 bg-blue-500 直接不再生成,团队只能用你定义的品牌色。反过来,若某个令牌要给手写 CSS 用 var() 取、却没有对应的工具类,用 @theme static 强制输出它。--color-brand、--radius-card 是决策,值得进主题,因为改一次全站跟着变;某个弹窗的 top: 117px 是一次性的,写 top-[117px] 就好。判据:这个值以后可能被设计师统一调整吗?会——进 @theme;不会——任意值。@apply 能把一串工具类塞进一个 CSS 类里。它看起来是「消灭重复」的正解,但绝大多数时候它是错的解法——因为它把工具优先最值钱的两样东西原样还了回去。这一卡的核心不是语法,而是这个判断。
@apply 还回去了什么
- 局部性没了。
.btn一旦存在,改它就又要担心「还有谁在用」——你回到了当初那个不敢改只敢加的世界。 - 又要起名了。
.btn/.btn-primary/.btn-primary-sm,BEM 那套命名负担原封不动地回来了。 - 还多了一层间接。读 HTML 看到
class="btn",得跳到 CSS 文件才知道它长什么样,比读一串工具类更慢。
Tailwind 作者自己反复讲过这件事:重复的解药是组件,不是 CSS 类。
正确的复用单位:组件
同一串类出现三次以上,抽的应该是一个 React/Vue/Svelte 组件、或一个模板 partial(Astro 组件、Rails partial、Django include),把类串关在组件内部。这样:变体用 props 表达而不是靠拼类名、外部想覆盖时通过 className 传入(配合 16 章的 cn())、组件删掉样式自然跟着消失。
@apply 真正合理的少数场景
- 你改不了的 HTML:第三方脚本注入的 DOM、后端渲染的富文本、CMS 输出的内容——加不上 class,只能从 CSS 侧选中它。
- Markdown 正文:
prose覆盖不到、或需要在prose之外做全局排版基线时。 - 真·全局基础样式:给
<body>定字体和底色这种全站唯一、不会长出变体的东西。
共同特征都是「没有组件层可用」。有组件层却还在 @apply,基本就是走错了路。
/* ✅ 合理场景:改不了的第三方 HTML / Markdown 正文 */
.markdown-body h2 {
@apply mt-8 mb-3 text-2xl font-semibold;
}
/* ✅✅ 更推荐:有组件层就用组件,类串关在内部 */
// function Button({ variant = "primary", className, ...rest }) {
// return <button className={cn(
// "inline-flex items-center px-4 py-2 rounded-lg font-medium transition",
// variant === "primary" && "bg-blue-600 text-white hover:bg-blue-700",
// variant === "ghost" && "text-blue-600 hover:bg-blue-50",
// className,
// )} {...rest} />;
// }
/* 组件的 scoped style / CSS Modules 里用 @apply:
这类文件是被单独编译的,拿不到主题,需要显式引用主题来源 */
@reference "tailwindcss";
.card { @apply p-4 text-blue-500; }
/* 想扩展工具类本身(而不是造语义类),v4 用 @utility */
@utility scrollbar-none {
scrollbar-width: none;
}<style scoped> 或 CSS Modules 里用 @apply,默认会失败或拿不到你的主题令牌——这类文件被构建工具当成独立的 CSS 单独编译,那份编译里根本没有 @import "tailwindcss",自然不知道 p-4、text-brand 是什么。解法是在该文件顶部显式引用主题来源(v4 提供 @reference "tailwindcss";,或指向你自己的入口 CSS),它只把主题信息读进来供 @apply 解析、不会把整个 Tailwind 产物复制进这个文件。忘了这一行,症状通常是构建报「找不到工具类」或者样式静悄悄地不生效。@apply 表达变体只能靠 .btn-primary-sm 这种类名笛卡尔积,几个维度之后就爆炸了。这也是 BEM 当年最痛的地方。v4 用一行 @plugin 引插件(v3 的 plugins: [require(...)] 已成历史)。真正值得花时间理解的不是语法,而是生态里那些「组件库」其实分成性质完全不同的两类——选错会直接影响你后面两年的定制自由度。
两个几乎必装的官方插件
@tailwindcss/typography:提供prose类,给一整块你无法逐个加类的富文本(Markdown 渲染结果、CMS 内容)套上像样的排版——标题层级、段间距、列表、代码块、引用一次到位。配prose-lg调大小、dark:prose-invert适配暗色。@tailwindcss/forms:把浏览器各不相同的原生表单控件重置成统一的、可用工具类继续修饰的基线。做表单几乎一定要它,否则<select>和<input type="checkbox">在各浏览器长得没法看。
组件生态的两种形态
- 依赖式组件库(daisyUI、Flowbite 等):装成 npm 依赖,用它提供的类名或组件。上手最快,但样式定制受限于它开放了多少接口,升级还可能带来破坏性变更。
- 复制式(shadcn/ui 是代表):它不是一个你安装的依赖,而是一堆你复制进自己项目的源码。CLI 把组件文件写进你的
components/目录,从此那就是你的代码——想改直接改,没有版本锁定,也没有「库不支持这个 prop 怎么办」。代价是升级得自己合并,以及项目里多了一批需要你负责的代码。 - 无样式(headless):Headless UI、Radix、React Aria 只管交互逻辑、键盘操作和无障碍属性,一行样式都不给,视觉全部由你用工具类填(配合 16 章的
data-*变体)。shadcn/ui 本质上就是「Radix 的逻辑 + 一套 Tailwind 皮肤」的复制式发行。
@import "tailwindcss";
@plugin "@tailwindcss/typography";
@plugin "@tailwindcss/forms";
<!-- typography:整块富文本一键排版 -->
<article class="prose lg:prose-lg dark:prose-invert max-w-none">
<h1>标题</h1><p>正文自动获得合理的字号、行高、间距…</p>
</article>
<!-- prose 的局部覆盖:prose-<元素>:<工具类> -->
<article class="prose prose-a:text-brand prose-img:rounded-lg">prose 会给它内部的所有元素加样式,而这些样式常常盖过你手写的工具类——在 prose 容器里给 <a> 加 text-blue-500 可能不生效,因为插件的 .prose a { … } 特异性更高。正确做法是用插件提供的 prose-a:text-blue-500 这类元素修饰变体,而不是硬提权。另外 prose 自带 max-width(约 65ch,为了可读性),套在一个本该铺满的容器上会莫名变窄——要铺满得显式加 max-w-none。Tailwind 不是 CSS 方案的终点,只是版图上的一格。12 章讲了作用域、层叠管理和命名方法论这些共性问题,这里把四种主流方案放在一张表里对照,再给一份能直接抄的落地清单。
四种方案横评
| 原生 CSS | Tailwind | CSS Modules | CSS-in-JS | |
|---|---|---|---|---|
| 样式作用域 | 全局,靠命名约定(BEM)自律 | 无所谓——样式绑在元素上 | 编译期改名,天然局部 | 运行期生成类名,天然局部 |
| 设计约束 | 无,全靠 review | 强:默认只能选刻度里的值 | 无(可配 CSS 变量自建) | 弱~中(靠 theme 对象) |
| 运行时开销 | 无 | 无(构建期产出静态 CSS) | 无 | 有:需在浏览器里生成/注入样式 |
| 构建依赖 | 无,可裸写 | 必须有构建(或 CLI) | 需打包器支持 | 需运行库(部分方案支持编译期抽取) |
| 动态样式 | 靠 CSS 变量 | 靠 CSS 变量 / 有限的任意值 | 靠 CSS 变量 | 最强:直接用 props 算样式 |
| 产物体积随项目增长 | 只增不减,越老越大 | 趋于收敛:类复用,新页面几乎不加新 CSS | 线性增长 | 线性增长 + 运行时 |
| 适合 | 小站点、演示页、学习 | 有组件层的产品、设计系统、快速迭代 | 已有 CSS 资产、偏好写标准 CSS 的团队 | 主题高度动态、样式强依赖运行期状态 |
它们可以共存:用 Tailwind 做布局与常规样式,个别复杂组件用 CSS Modules 写标准 CSS,两边共享 @theme 里的同一份 CSS 变量令牌。
落地清单(新项目照着做)
- ① 装两个工具:
prettier-plugin-tailwindcss(按官方顺序自动排类名,从此没人吵顺序)+ 编辑器的 Tailwind IntelliSense(补全、悬停看真实 CSS、颜色预览)。这两样几乎抵消了「类名长」的大半痛苦。 - ② 先定
@theme:颜色、字体、圆角、断点先和设计对齐,写进令牌,别一边做一边攒任意值。 - ③ 重复三次就抽组件,不是抽 CSS 类。
- ④ 可复用组件一律接
className+cn(),让调用方能覆盖(见 16 章)。 - ⑤ 暗色和主题走令牌,别在每个元素上手写
dark:的颜色对——把语义令牌(--color-surface、--color-fg)定义好,暗色只切换令牌的值。
<!-- 最佳实践 -->
<!-- 1. 重复 3+ 次 → 抽成框架组件,而非复制类串 -->
<!-- 2. 用 prettier-plugin-tailwindcss 自动排序类名 -->
<!-- 3. 设计值进 @theme 令牌,少用 [任意值] -->
<!-- 4. 装 VS Code「Tailwind CSS IntelliSense」补全 -->
<!-- 5. 暗色用 dark: + data-theme,颜色走 oklch 令牌 -->
<!-- 三方案可共存:Tailwind 布局 + CSS Modules 复杂组件 -->w-[437px]、bg-[#3b7dd8] 这类任意值,每一个都会生成一条独立规则,复用率归零,体积优势也就没了——任意值用得越多,你越是在用一种啰嗦的语法写原生 CSS。任意值超过一两成就该回头补 @theme 令牌了。bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 是暗色模式最常见的失控方式——每个颜色都要写两遍,加第三套主题时全部重写。改成语义令牌后只写 bg-surface text-fg,暗色靠 [data-theme=dark] 下改写变量值实现,加多少套主题都不用动 HTML。Tailwind 进阶与实战
把工具类用到实战水平:父子兄弟联动、间隔方案的取舍、三种逃生舱、属性驱动样式与自定义变体、暗色双策略,以及和组件框架结合时绕不开的 cn() 合并模式与调试手法。
工具类天生只能描述「这个元素自己的状态」。但 UI 里大量需求是「A 变了,B 跟着变」——卡片 hover 时里面的箭头位移、勾选框选中时下面的提示出现。group 和 peer 就是补这个缺口的两把钥匙,纯 CSS,零 JS。
group:跟着祖先的状态变
祖先加 group,后代用 group-hover: / group-focus: / group-has-[…]:。编译出来就是祖先带 group 且处于该状态时,选中这个后代。典型用途:整张卡片 hover 时统一改变里面的图标、标题、边框。
peer:跟着前面兄弟的状态变
前一个兄弟加 peer,后面的元素用 peer-checked: / peer-focus: / peer-invalid: / peer-placeholder-shown:。这是纯 CSS 实现表单校验提示、开关联动的关键——JS 一行不用写,状态直接来自表单控件自身的伪类。
具名 group:嵌套时必须用
多层 group 嵌套时,group-hover: 会匹配「任意一层祖先 group」,内层与外层互相干扰——列表项 hover 时整个列表的删除按钮全亮出来就是这么来的。解法是起名:容器写 group/item,后代写 group-hover/item:opacity-100,明确绑定到那一层。只要页面上可能出现嵌套 group,就一律具名,别赌。peer/name 同理。
<!-- group:父 hover 改子(卡片整体 hover 时图标位移)-->
<a class="group flex items-center gap-2">
<span>查看详情</span>
<span class="transition group-hover:translate-x-1">→</span>
</a>
<!-- peer:checkbox 勾选时显示提示(零 JS)-->
<input type="checkbox" class="peer">
<p class="hidden peer-checked:block">已同意条款</p>
<!-- peer 表单校验:输入非法时变红 -->
<input required class="peer">
<span class="hidden peer-invalid:block text-red-500">必填</span>
<!-- 嵌套:命名 group 避免相互干扰 -->
<li class="group/item">
<button class="opacity-0 group-hover/item:opacity-100">删除</button>
</li>peer 只能影响它之后的兄弟元素,因为它编译成 CSS 的兄弟组合器——无法回头影响前面的元素,更不能影响父级。所以「输入框聚焦时,它上方的 label 变色」这个极常见的需求,用 peer 直接做不到。两条出路:① 调整 DOM 顺序,把 label 写在 input 之后,再用 flex flex-col-reverse 在视觉上把它放回上面;② 改用父级 has-[]:<div class="has-[input:focus]:text-blue-600">。has-[] 变体能反向影响父级,弥补 peer 只能向后、group 只能向下的短板:<label class="has-[:checked]:bg-blue-50 has-[:checked]:ring-2"> 让整个 label 在内部 checkbox 勾选时高亮——做「可点击的卡片式单选」时比 peer 直观得多,也不必迁就 DOM 顺序。还能和 group 组合成 group-has-[:checked]:。「让子元素之间有间距」有三种工具,它们的实现机制完全不同,坑也完全不同。结论先放这儿:能用 gap 就用 gap,另外两个是它覆盖不到时的补充。
三者对照
gap-4 | space-y-4 | divide-y | |
|---|---|---|---|
| 底层机制 | 真正的 gap 属性 | 给「除最后一个外的直接子元素」加 margin | 给「除最后一个外的直接子元素」加 border |
| 要求容器 | 必须 flex / grid | 任意块容器都行 | 任意块容器都行 |
| 换行时 | 行与行之间也正确留白 | 会错乱 | 会错乱 |
| 首尾 | 不产生外边距 | 不产生 | 不产生 |
| 作用 | 纯间距 | 纯间距 | 间距靠子元素 padding,它只画线 |
space-* 的三个真实故障
- 遇上
flex-wrap换行就废。它按「文档顺序里不是最后一个」加 margin,根本不知道哪里换了行,于是行尾多出间距、行首却没有。flex-row-reverse同理会整体错位。 - 和
divide-*叠加会打架:两者都在同一批子元素上做文章,间距和分隔线的位置容易对不齐。要「有线又有间距」,正确做法是divide-y负责线、子元素各自py-3负责间距,不要再叠space-y。 - 它是零特异性的:v4 里这些规则被包在
:where()里,特异性为 0,任何一条你自己写的 margin 都能盖掉它。这有时方便,有时让你困惑「为什么space-y-4没效果」——检查子元素上是不是有mt-*/mb-*。
那什么时候还用 space-*
只有一种:容器不能变成 flex/grid。例如一段由 CMS 输出的、你不能改容器 display 的文章正文,或者改成 flex 会破坏其他行为(float、margin 折叠依赖、<p> 的常规流表现)的场景。除此之外,把容器改成 flex flex-col gap-4 永远更稳。
<!-- ✅ 首选:flex/grid + gap,换行也正确 -->
<div class="flex flex-col gap-4">...</div>
<div class="flex flex-wrap gap-4">...</div> <!-- 换行安全 -->
<!-- ❌ 换行 + space:行尾多间距、行首没间距 -->
<div class="flex flex-wrap space-x-4">...</div>
<!-- space:只在容器不能变 flex/grid 时用 -->
<div class="space-y-4">
<p>段一</p><p>段二</p>
</div>
<!-- divide:线由 divide 画,间距由子元素 padding 给 -->
<ul class="divide-y divide-gray-200">
<li class="py-3">项 1</li>
<li class="py-3">项 2</li>
</ul>space-* 和 divide-* 都只作用于直接子元素。中间随手包一层 <div>(很容易在加条件渲染、加 Fragment 包裹时发生),间距和分隔线就整个消失了——而你的 class 一个字都没改,这类「昨天还好好的」故障排查起来相当费时间。gap 也是同理(只管直接子项),但因为它是原生属性,行为至少和你在 08 章学到的完全一致,不会有额外惊喜。gap-*。不能 → space-y-*(且确认不会换行)。要分隔线 → divide-y + 子元素自己 py-*。另外 gap 在 columns-* 多栏布局里同样有效,不止 flex/grid。设计系统总有覆盖不到的角落。Tailwind 给了三种逃生舱,共同点都是方括号 [],但作用在类名的不同位置,对应三件不同的事。
三种逃生舱,看方括号在哪
| 形态 | 方括号位置 | 作用 | 例 |
|---|---|---|---|
| 任意值 | 值的位置 | 给已有工具类塞自定义值 | w-[37px] bg-[#1da1f2] |
| 任意属性 | 整个类都是括号 | 写 Tailwind 没有的 CSS 属性 | [mask-type:luminance] |
| 任意变体 | 冒号前的前缀位置 | 把任意选择器变成变体 | [&>p]:mt-4 |
任意变体:& 代表元素自己
方括号里写的是一个 CSS 选择器片段,& 是当前元素的占位符:[&>p]:mt-4 编译成 .xxx > p { margin-top: … };[&:nth-child(3)]:bg-red-100、[&_svg]:size-4(后代 svg,下划线代表空格)也是常用形态。给一个不能逐个加类的富文本容器批量设样式时特别顺手。
下划线规则与它的例外
类名里不能有空格(会被浏览器当成两个类),所以值里的空格一律写成下划线 _,Tailwind 编译时还原:shadow-[0_2px_8px_rgba(0,0,0,0.1)]、grid-cols-[200px_1fr]。反过来,值本身确实需要下划线时(罕见,例如某些字体名)要转义成 \_。另外,URL 里的下划线不会被误换,因为 Tailwind 知道 url() 内不该替换。
<!-- 任意值:精确像素 / 品牌色 / CSS 变量 -->
class="top-[117px] bg-[#1da1f2] text-[14px]"
class="grid-cols-[200px_1fr] h-[calc(100dvh-4rem)]"
class="bg-[var(--brand)]" <!-- 引用自己的变量 -->
<!-- 任意属性:Tailwind 没有的属性直接写 -->
class="[mask-type:luminance] [scrollbar-width:none]"
<!-- 任意变体:把任意选择器变成前缀 -->
class="[&>p]:mt-4" <!-- 直接子 p 都加上边距 -->
class="[&:hover>svg]:rotate-90" <!-- 悬停时内部 svg 旋转 -->text-[var(--x)] 到底是字号还是颜色?bg-[--x] 是颜色还是背景图?遇到这种情况用数据类型提示:text-[color:var(--x)]、text-[length:var(--x)]、bg-[image:var(--x)],把冒号前的类型写出来即可。症状是「值明明对,但生成的 CSS 属性不是我想要的那个」。另一个坑:任意值里带
%、#、引号等字符时,若写在模板字符串或某些模板引擎里可能被转义,最稳的是先在 DevTools 里确认生成的类名和你想的一模一样。@theme 令牌(见 15 章);同一串任意变体反复出现,说明该抽成组件。一个满是 [...] 的项目,等于用一种更啰嗦的语法写原生 CSS,还把 Tailwind 的约束和产物复用全丢了。现代无头组件库(Radix、Headless UI、React Aria)不给样式,但会把内部状态暴露成 DOM 属性:data-state="open"、aria-expanded="true"、data-disabled。Tailwind 的属性变体正好接上这一层,形成一种非常干净的分工:JS 只负责改属性,样式全部留在 class 里。
三种属性变体
- 布尔式:
data-open:rotate-180、aria-disabled:opacity-50——data-*不带方括号的裸写法编译成属性存在选择器([data-active]),只要属性在就命中,不管它的值是什么(这一点正是下面 pitfall 的祸根)。但aria-*不同:裸写法编译出来是[aria-disabled="true"]的等值选择器,天然只认真值,不会被"false"命中。 - 等值式:
data-[state=open]:block、data-[state=closed]:hidden、aria-[expanded=true]:bg-gray-100——最常用,和 Radix 的约定天然对齐。 - 组合:
group-data-[state=open]:rotate-180——祖先的属性状态驱动后代样式,做折叠面板箭头旋转的标准写法。
为什么这套分工值得推广
对比传统写法:JS 里 el.classList.add("is-open"),然后你要在某个 CSS 文件里维护 .is-open .arrow { transform: … }——状态和样式分居两地,改一处忘一处。属性驱动之后,JS 只做「把 data-state 切成 open」这一件语义化的事,所有视觉表现都写在它作用的那个元素的 class 上,读代码时不用来回跳。顺带地,aria-* 变体让你「为了写样式」也不得不把无障碍属性写对——这是少见的、正确的事更省事的设计。
自定义变体:@custom-variant
某个选择器条件反复出现,就用 @custom-variant 给它起个名字(v4 的 CSS 写法,取代 v3 插件里的 addVariant)。语法是 @custom-variant 名字 (选择器);,其中 & 代表被修饰的元素。定义之后 名字:任意工具类 就可用了。
<!-- data 属性驱动(配合 Radix / Headless UI)-->
<div data-state="open"
class="data-[state=open]:block data-[state=closed]:hidden">
<!-- group + data:祖先状态驱动后代(折叠面板箭头)-->
<button class="group" data-state="open">
详情
<svg class="transition group-data-[state=open]:rotate-180"></svg>
</button>
<!-- aria 属性:样式与无障碍一举两得 -->
<button aria-expanded="true"
class="aria-[expanded=true]:bg-gray-100">菜单</button>
/* app.css —— v4 自定义变体,& 代表元素自身 */
@custom-variant is-active (&[data-active="true"]);
/* 之后即可 class="is-active:text-blue-600" */
/* 名字别叫 data-active —— 会遮蔽内置的 data-* 通用变体 */data-[state=open] 是字符串精确匹配,不是「真值判断」。data-state="Open"(大小写不同)、data-state=" open"(带空格)都不匹配。而在 React 里更常见的是另一个坑:<div data-open={isOpen}>,当 isOpen 为 false 时 React 会渲染成 data-open="false" 而不是移除属性(data-* 不像 disabled 那样有布尔特殊处理)——于是「属性存在即匹配」的裸写法在 false 时照样命中。稳妥写法是让 JS 端在假值时给 undefined(React 才会真正省略该属性),或者一律用 data-[open=true] 这种等值式。data-* 变体前,先去查你用的那个组件库暴露了哪些属性(Radix 每个组件的文档都有一张 Data attribute 表)。这些属性就是它和你之间的公开契约——照着写,组件库升级时样式基本不会崩;靠猜内部 class 名去覆盖,下次升级必挂。dark: 变体默认走 media 策略:编译成 @media (prefers-color-scheme: dark),跟随系统设置,零配置零 JS——但用户没法在你的站内单独切换。想要一个切换开关,就得把 dark 这个变体重新定义成看某个 class 或属性。
两种策略怎么选
- media 策略(默认):什么都不用做。适合内容型站点、博客、文档——尊重系统偏好通常就是用户想要的。
- class / 属性策略:用
@custom-variant dark (…)覆盖掉默认定义,再由 JS 切换<html>上的class="dark"或data-theme="dark"。适合产品型应用,也是做「跟随系统 / 亮 / 暗」三态开关的唯一办法。
选属性还是 class?只有明暗两态用 class 更省事;将来可能有第三套主题(比如高对比度、品牌换肤)就用 data-theme,因为属性天然能承载多个值,而 class 得自己管互斥。
选择器必须同时覆盖「自己」和「后代」
这是重定义 dark 时最容易写错的一行。&:where(.dark *) 只匹配 .dark 的后代——挂着 .dark 的 <html> 元素自身不在其中。如果你在 <html> 上写了 dark:bg-gray-900,它就不会生效。正确写法是把两者都列上:&:where(.dark, .dark *),属性版同理写成 &:where([data-theme=dark], [data-theme=dark] *)。用 :where() 包起来是为了不增加特异性,避免 dark: 的规则意外盖过你其他的样式。
更好的做法:暗色只切令牌
满页写 bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 会让每个颜色都要维护两遍。规模上来后应该改成语义令牌:HTML 里只写 bg-surface text-fg,在 [data-theme=dark] 选择器下改写 --app-surface / --app-fg 的值(配合 15 章的 @theme inline)。加第三套主题时 HTML 一个字都不用动。
<!-- 默认 media 策略:跟随系统,零配置 -->
<div class="bg-white text-black dark:bg-gray-900 dark:text-white">
/* 改为手动 class 策略:自己 + 后代都要覆盖 */
@import "tailwindcss";
@custom-variant dark (&:where(.dark, .dark *));
/* 或属性策略,配合 data-theme —— 同样要写全两段 */
@custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *));
// JS:切换开关
document.documentElement.dataset.theme =
isDark ? "dark" : "light";
<!-- 防 FOUC:放在 <head> 里,早于任何渲染执行 -->
<script>
const t = localStorage.getItem("theme")
?? (matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light");
document.documentElement.dataset.theme = t;
</script>dark 变体时只写后代选择器,是最隐蔽的一个坑:&:where([data-theme=dark] *) 编译出的就是一个后代选择器,挂着 data-theme="dark" 的 <html> 自己不匹配。症状是页面整体切暗了,唯独设在 <html> 或 <body>(如果属性挂在 body 上)上的 dark:bg-* 不生效,露出一圈白底。务必写成 &:where([data-theme=dark], [data-theme=dark] *)。另一个必踩的是首屏闪白(FOUC):HTML 解析完才轮到 JS 加属性,用户会先看到一帧亮色。解法是在
<head> 里放一段内联的阻塞脚本(不能用 defer/async、也不能打包成外部模块),在首次渲染前就把属性写上去。"light" / "dark" / 不存(或存 "system")三种状态,读不到值时才去问 matchMedia。很多实现把「跟随系统」也存成一个具体值,结果用户切换系统主题后站点纹丝不动——因为它记住的是当时算出来的结果,而不是「跟随」这个意图。做可复用组件时一定会碰到这个需求:组件给一套默认样式,调用方能覆盖其中几个。天真的做法是把外部传入的 className 拼在后面——然后你会发现它时灵时不灵。搞懂为什么,是理解 Tailwind 与 CSS 关系的一个关键节点。
核心机制:HTML 里的顺序不决定任何事
class="p-4 p-2" 到底是 16px 还是 8px?答案是:跟你写的顺序完全无关。class 属性只是一个「我属于哪些类」的无序集合,浏览器不会因为 p-2 写在后面就让它赢。真正决定胜负的是层叠规则:两条规则特异性相同(都是单个类选择器)时,看谁在 CSS 文件里出现得更晚。而 CSS 文件里的顺序由 Tailwind 生成时决定——它按自己的内部排序输出所有工具类,跟你在哪个组件里怎么写毫无关系。
结果就是:p-4 和 p-2 谁赢是固定的(由 Tailwind 的生成顺序决定),但不是你能控制的,而且往往不是你想要的那个。组件默认值有时被覆盖成功、有时失败,就是这么来的。
解法:在类进入 HTML 之前就去重
既然运行期的层叠救不了你,就得在拼接类名的那一刻把冲突解决掉——这正是 tailwind-merge 做的事。它认识 Tailwind 的类语义,知道 p-4 和 p-2 属于同一个「padding」组、px-2 只和 px-*/p-* 冲突而不和 py-* 冲突、text-lg 和 text-red-500 分属字号和颜色两组不冲突。同组内只保留最后一个,于是「后写的赢」这条你本来就期待的直觉,终于变成真的了。
再配上 clsx:条件类
clsx(或 classnames)负责另一半:把布尔值、对象、数组、undefined 摊平成一个类名字符串,让 active && "bg-blue-600" 这种写法能用。两者串起来就是社区标配的 cn():twMerge(clsx(inputs))——先摊平,再去重。shadcn/ui 生成的每个组件里都有这一个函数。
// lib/utils.ts —— 社区标准 cn() 工具
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
// 组件里:条件类 + 允许外部覆盖
function Button({ active, className }) {
return <button className={cn(
"px-4 py-2 rounded",
active && "bg-blue-600 text-white",
className // 外部传 px-8 会正确覆盖 px-4
)} />;
}tailwind-merge 不认识你自定义的工具类和插件类。你在 @theme 里加的 --text-hero 生成的 text-hero、或 @utility 定义的自定义类,它默认无法归组,于是该覆盖的没覆盖。需要用 extendTailwindMerge 告诉它这些类属于哪一组。同理,如果你的项目给 Tailwind 配了前缀,也要相应配置,否则整个 merge 静默失效——症状是「装了 cn() 但覆盖还是不生效」。还有一点常被忽略:
cn() 只解决同一个元素上的类冲突。父组件想改子组件内部某个元素的样式,className 帮不上忙——那需要组件显式开放插槽式的 API(如 classNames={{ header: "…" }})。className:cn("默认类…", 条件类, className)。顺序很重要——className 必须在最后,tailwind-merge 才会让它覆盖前面的默认值。这一行约定,就是「组件给默认、调用方可改」从「大概能行」变成可靠契约的全部代价。这一卡收尾:三个几乎人人踩过的反模式,以及一套「类不生效」的标准排查流程。
反模式一:动态字符串拼类名(头号坑)
Tailwind 生成 CSS 的方式是把你的源码当纯文本扫一遍,找出长得像类名的完整字符串。它不执行你的代码,也不做任何推断。所以 `text-${color}-500` 在源码里根本不存在 text-red-500 这个连续字符串,产物里也就没有这条规则——运行时 DOM 上有这个 class,但没有任何 CSS 与之对应。解法是写出完整静态类名,通常用一个映射对象。少数确实无法静态化的(例如类名来自数据库),v4 可以用 @source inline("…") 显式声明要生成哪些类(v4.1 起提供,取代 v3 config 里的 safelist)。
反模式二:靠 ! 提权掩盖问题
v4 的 important 是后缀式:mt-0!、hover:p-4!(v3 的前缀式 !mt-0 在 v4 下仍然可用,两者都生成 !important,但官方推荐后缀式,新代码统一用它)。问题不在语法而在用法:需要 !important 通常说明另有原因——要么该用 cn() 解决类冲突,要么是某个插件(比如 prose)的规则特异性更高、该用它自己的修饰变体。提权是止痛药,不是诊断;用之前先花 30 秒在 DevTools 里看清到底是谁盖了谁。
反模式三:@apply 滥用
把大段工具类搬进 CSS 文件、造出一整套 .btn / .card / .badge——这等于绕一大圈回到了原生 CSS,还多背了一层 Tailwind 依赖。详见 15 章。
「类不生效」的三步排查
- ① 这个类到底生成了没有? 在 DevTools 的 Elements 面板选中元素,右边 Styles 里找它——连规则都不存在就是扫描问题(动态拼接、或文件不在扫描范围内);规则在但被划掉,就是被覆盖了,往上看是谁赢了(01 章讲过怎么打开和读这个面板)。
- ② 类名对不对? 编辑器装 Tailwind IntelliSense,鼠标悬停在类名上会直接显示它编译出的真实 CSS;没有提示就说明这个类名不存在(拼错、或者是 v3 的旧名字)。这是最快的一步,先做它。
- ③ 产物里到底有什么? 跑一次
npx @tailwindcss/cli -i app.css -o out.css,然后在out.css里搜你的类名。这是终极裁判——能排除掉所有关于「构建有没有跑对」的猜测。
<!-- ❌ 动态拼接:源码里没有完整类名,Tailwind 扫不到 -->
<div className={`text-${color}-500`}>
// ✅ 完整类名映射,确保能被扫描到
const COLORS = {
red: "text-red-500",
blue: "text-blue-500",
};
<div className={COLORS[color]}>
/* 实在无法静态化时(v4.1+):显式声明要生成的类 */
@source inline("text-red-500 text-blue-500 text-green-500");
<!-- 提权:v4 用后缀式(v3 前缀式仍兼容,但别再写了)-->
class="mt-0!" <!-- ✅ v4 推荐:margin-top:0 !important -->
class="!mt-0" <!-- v3 遗留写法,v4 下仍有效 -->
class="hover:p-4!" <!-- 带变体时,! 也放在最后 -->
# 终极裁判:直接看产物里有没有这个类
npx @tailwindcss/cli -i app.css -o out.css
grep "text-red-500" out.css.gitignore 里的文件,把组件放进被忽略的目录(例如某些约定的生成目录)就会静默失效,需要 @source 显式加回来。这三条排完再怀疑类名写错。cva(class-variance-authority)这类库,它们的写法天然是完整类名,还顺带把变体组合管起来。@source inline(…) 留给真正无法静态化的场景(类名来自数据库或用户配置),别拿它当拼接失效的常规解药——那等于把整个色板全量生成一遍。组件库:无头、成套与复制进项目
前面十六章把 HTML、CSS 与 Tailwind 讲完了,但真实项目里没人从零手写下拉菜单和对话框。这一章讲清楚组件从哪来:三个流派各自卖给你什么、无头库到底替你扛下了哪一层,以及什么时候自己写反而更划算。
「组件库」这个词底下其实是三种不同的东西。分清它们,选型问题就从「哪个更好」变成「我需要哪一层」——而这个问题是有答案的。
一个对话框由四层组成
- ① 行为:开合、点外部关闭、Esc 关闭、锁住页面滚动;
- ② 无障碍:
role="dialog"、标题关联、把弹窗外的内容对读屏器屏蔽、焦点进入与归还、键盘不能 Tab 出去; - ③ 样式:圆角阴影配色间距动画;
- ④ 组合接口:能不能换标签、能不能塞自己的按钮、受控还是非受控。
三个流派的差别就是它们给你哪几层:成套库四层全给(MUI、Ant Design、Element Plus),无头库给 ①②④ 而样式一行不给(Radix、Base UI、Headless UI、React Aria),copy-in(shadcn/ui)则是把「无头库 + Tailwind 样式」的源码复制进你的仓库,从此那是你自己的代码。
为什么「样式」反而是最不值钱的一层
- 写样式是你已经会的事(前面十六章都在讲这个),而 ② 那一层几乎没有人凭直觉能写对——焦点陷阱、
aria-labelledby、外部内容屏蔽,任何一条漏掉,键盘与读屏器用户就用不了; - 所以「用了 Tailwind 之后还需要什么」的答案通常是:需要行为与无障碍,不需要别人的视觉——这正是无头库的定位,也是 shadcn 这套组合流行的原因;
- 本站的 UI 库页整页在讲这件事(各流派对照、体积、无障碍契约),这里只给出选择的框架。
三条选择判据
- 产品的视觉要像我们自己,还是像个正常的后台就行?后者选成套库,别折腾;
- 团队有没有人维护组件?copy-in 意味着你拥有了一个内部组件库,需要有人负责;
- 项目会活多久?成套库的大版本迁移(MUI v5→v9、antd v4→v6)是以周计的工作量,短期项目不必在意,长期产品要提前想。
<!-- 同一个对话框,三种流派的写法差异 -->
<!-- ① 成套库:一行搞定,长得像它 -->
<Dialog open title="确认删除">确定吗?</Dialog>
<!-- ② 无头库:行为与无障碍归它,样式全归你 -->
<Dialog.Root>
<Dialog.Trigger class="rounded bg-slate-900 px-4 py-2 text-white">删除</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Overlay class="fixed inset-0 bg-black/40" />
<Dialog.Content class="fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 rounded-lg bg-white p-6">
<Dialog.Title>确认删除</Dialog.Title> <!-- 自动接上 aria-labelledby -->
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
# ③ copy-in:源码复制进你的仓库,从此归你改
$ npx shadcn@latest add dialog # 落进 components/ui/dialog.tsx无头库第一眼看上去像坏了:渲染出来是一堆没有任何视觉的 div。但打开 DevTools 看它写进 DOM 的属性,就明白你买到的是什么。
它做了什么(真 Chrome 里抓的)
- 对话框元素上出现
role="dialog"、tabindex="-1"、data-state="open",aria-labelledby自动指向你写的标题; - 触发按钮被改写成
aria-haspopup="dialog"aria-expanded="true",关闭后又变回false; - 焦点自动进入弹窗、连按 Tab 出不去、Esc 关闭后焦点回到触发按钮;
- 打开期间
<body>被加上overflow: hidden并补偿滚动条宽度,关闭后自动清除;弹窗之外的内容被inert或aria-hidden屏蔽。
这几条就是「模态」的完整定义。它们与视觉无关,却是自己写最容易全部漏掉的部分。
样式怎么接上去:data 属性
- 无头库把状态写成 DOM 属性,于是你的 CSS 直接挂上去就行:
[data-state="open"]、[data-highlighted]、[data-disabled]、[data-side="top"]; - Tailwind 里对应
data-[state=open]:opacity-100这种写法——15 章讲的变体机制在这里正好派上用场; - 各家的命名不同:Radix 用
data-state="open"(带值),Base UI 用data-open(无值,关闭时属性消失)。照抄别家的选择器会全都不生效。
代价:一点体积,和「看起来坏了」的初印象
- (Vite 生产构建、gzip、相对一个空白 React 应用):Radix 的一个对话框约 +12 KB,Headless UI 约 +18 KB,React Aria 约 +22 KB;相比之下一个成套库的第一个组件就要 +35~77 KB;
- 无头组件默认没有任何定位:
Dialog.Content就是文档流里的一个 div,会出现在页面底部而不是屏幕中央——必须自己写fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2; - 这不是 bug,是「无头」的字面意思。知道这一点,第一次接入时就不会以为自己配错了。
/* 无头组件的样式就是普通 CSS,挂在它写出来的属性上 */
[data-state="open"] .panel { opacity: 1; transform: none; }
[data-highlighted] { background: var(--color-accent-soft); }
[data-disabled] { opacity: .5; pointer-events: none; }
[data-side="top"] .arrow { rotate: 180deg; }
/* Tailwind 里的等价写法 */
// class="data-[state=open]:opacity-100 data-[side=top]:slide-in-from-bottom-2"
/* 必须自己写的那部分:定位与遮罩 */
.overlay { position: fixed; inset: 0; background: rgb(0 0 0 / .4); }
.content {
position: fixed; left: 50%; top: 50%;
translate: -50% -50%; /* 09 章的变换属性,这里是最常见的用法 */
background: var(--color-surface);
border-radius: .75rem; padding: 1.5rem;
}<body> 下时,只要祖先上有 overflow: hidden、transform、filter 或 contain,弹窗就会被裁掉一半或者定位到奇怪的地方,而且 z-index 调多大都没用——因为 transform 会创建新的层叠上下文(07 章)。症状是「在别的页面好好的,放到这个卡片里就坏了」,排查时先往上找有没有这几个属性。不是所有组件都值得引库。浏览器这几年补了不少东西,而有些组件自己写的成本远低于想象——真正难的只有那么几个。
可以自己写(甚至根本不用写)
- 折叠面板 → 原生
<details><summary>:零 JS、可被 Ctrl+F 搜到(浏览器会自动展开)、同组加相同name属性还能互斥; - 简单模态 → 原生
<dialog>+showModal():自带焦点陷阱、Esc 关闭、::backdrop遮罩,还会进 top layer(永远压在所有 z-index 之上)。它不做的只有滚动锁与退场动画; - 简单浮层 →
popover属性 +popovertarget:不写 JS 就有开合与点外部关闭; - 标签页、面包屑、分页、进度条、吐司:结构简单,照着 ARIA 模式写一遍是很好的练习,也完全够用。
不要自己写
- 可搜索下拉 / 组合框:焦点必须留在输入框、靠
aria-activedescendant指向高亮项、还要处理输入法组合状态——细节多到几乎必错; - 日期选择器:时区、本地化、一周从哪天开始、键盘网格导航,复杂度不在 UI 而在时间本身;
- 带虚拟化的表格、富文本编辑器、拖拽排序:这三类都是「看起来两天、实际两个月」的典型;
- 判据:只要涉及「焦点在多个元素之间按规则移动」或「内容超出屏幕要虚拟化」,就别自己写。
自己写时的最低标准
- 用原生可交互元素做交互(
<button>而不是<div onclick>)——白拿键盘激活、焦点、禁用语义; - 状态用
aria-expanded/aria-selected/aria-current如实反映,别写死; - 焦点样式用
:focus-visible,永远不要只写outline: none; - 把状态写成
data-*属性,样式挂上去——这样你的自研组件和第三方组件用的是同一套样式写法,将来替换成本最低。
<!-- 原生就够用的三个例子 -->
<!-- 折叠:零 JS,还能被页内搜索找到 -->
<details name="faq">
<summary>运费怎么算?</summary>
<p>满 99 元包邮。</p>
</details>
<!-- 模态:焦点陷阱、Esc、backdrop、top layer 全自带 -->
<dialog id="dlg" aria-labelledby="t">
<h2 id="t">确认删除</h2>
<form method="dialog"> <!-- 按钮点了自动关闭,值进 returnValue -->
<button value="cancel">取消</button>
<button value="ok">删除</button>
</form>
</dialog>
<!-- 浮层:一行 JS 都不用 -->
<button popovertarget="menu">菜单</button>
<div id="menu" popover>…</div>
/* 自研组件的最低标准 */
:focus-visible { outline: 2px solid var(--color-primary); outline-offset: 2px; }dialog.show() 与 dialog.showModal() 差一个词,行为差一个世界:show() 是非模态——没有焦点陷阱、没有 ::backdrop、不进 top layer、Esc 也不关。它「能弹出来」所以看起来是对的,但键盘用户可以直接 Tab 到后面的页面上去,等于做了个假模态。需要模态就必须用 showModal()。<dialog> 里配 <form method="dialog"> 是个被严重低估的组合:表单里的按钮点击后自动关闭对话框,并把按钮的 value 写进 dialog.returnValue——一个「确认 / 取消」对话框的全部逻辑,一行 JS 都不用写。写原生确认框时应当默认用它。深水区:尺寸、行盒与轴向
前面十七章讲的是「怎么写」。这一章讲「浏览器怎么算」——尺寸从哪来、一行文字的高度由谁决定、上下左右在别的书写方向里是什么、溢出的三种处理有什么本质差别、几条尺寸约束打架时谁赢。这些规则平时不需要背,但每一条都对应一类「样式明明没错却对不上」的现场;本章每张卡的数字都是在本机 Chrome 里量出来的。
你写的每个 width 背后,浏览器都先算过两个「内容自己想要的宽度」。把这两个数搞清楚,一大批「为什么这个盒子比我设的宽」「为什么它非要撑破容器」就不再是玄学。这四个关键字都能直接写在 width / height 上。
四个关键字各是什么
min-content——「不溢出所能取的最窄宽度」:文本在每个可断处都断开,取最长那一段(通常是最长的单词);max-content——「完全不换行时的宽度」:整段排成一行有多宽;fit-content——min(max-content, max(min-content, 可用宽度)):内容少时按内容、内容多时不超过容器。它就是浮动元素和绝对定位元素的默认行为,也是「按内容宽度的按钮」的正确写法;stretch——撑满包含块(相当于把 margin 之外的空间吃掉)。它和width: 100%的区别在于100%不减 margin,所以带左右 margin 的元素写100%会溢出,写stretch不会。
一组数字把四个关键字钉死
- 容器宽 400px,字体 16px 等宽(每个字符正好 8px),内容是
the quick brown fox jumps(25 字符,最长单词 5 字符): width: min-content→ 40px(= 5 × 8,最长单词),高度被撑到 120px(5 行);width: max-content→ 200px(= 25 × 8,排成一行);width: fit-content→ 200px(内容装得下,等于 max-content);同一条规则配一个只有两字符的短内容 → 16px;width: stretch→ 400px(撑满容器);- 关键的反例:把内容换成一个 34 字符的不可断长单词后,
min-content变成 272px(= 34 × 8)——min-content不是「很窄」,它是「内容允许的最窄」,遇到不可断内容它可以非常宽。这正是下面那件事的根源。
min-width: auto 就是这么来的
- 08 章讲过 flex 子项和 grid 轨道会被长内容撑破,规则是「不许比内容的最小尺寸还窄」——这个「最小尺寸」正是
min-content; - (容器 300px,侧栏固定 80px,主项
flex: 1,内容是那个 36 字符不可断的长单词,其 max-content 为 288px):主项实得 288px、侧栏 80px,右边缘落在 x=368——整体溢出容器 68px; - 加一句
min-width: 0后主项变成 220px(= 300 − 80),布局恢复。Grid 侧完全同构:1fr实得 288px,改成minmax(0, 1fr)实得 220px; - 顺便解释一件 Tailwind 的事:
grid-cols-3生成的是repeat(3, minmax(0, 1fr))(编译产物),Tailwind 默认就替你写了那个minmax(0, …)——手写原生 CSS 时才需要自己记住这一条; - 另一条细节:
overflow不是visible时,隐式最小尺寸自动解成 0(同样测出 220px)。所以「加了overflow: hidden之后布局突然正常了」不是巧合——但靠它是副作用换来的,明写min-width: 0更干净。
什么时候真的会用到
- 按内容宽度的按钮 / 标签:
width: fit-content(比display: inline-block更可控); - 侧栏不许被内容挤宽:
grid-template-columns: minmax(0, 1fr) 16rem; - 表格列宽:
width: min-content能让「操作」这类列缩到刚好放得下按钮; - 撑满但保留 margin:
width: stretch替掉calc(100% - 2rem)这类手算。
/* 四个关键字的效果(容器 400px、等宽字体 8px/字符) */
.a { width: min-content; } /* 40px 最长单词 */
.b { width: max-content; } /* 200px 整段一行 */
.c { width: fit-content; } /* 内容少按内容、多则不超容器 */
.d { width: stretch; } /* 400px,且会为 margin 让位 */
/* 头号坑与它的两种解法 */
.layout { display: flex; width: 300px; }
.main { flex: 1; min-width: 0; } /* ✅ 明写,最清楚 */
.aside { flex: none; width: 80px; }
.grid { display: grid; grid-template-columns: minmax(0, 1fr) 5rem; }
/* 单行截断为什么必须配 min-width: 0 */
.title {
flex: 1;
min-width: 0; /* 少这一行,下面三条全部白写 */
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* 撑满但给 margin 留位置 */
.full { width: stretch; margin-inline: 1rem; } /* 不必写 calc(100% - 2rem) */min-content 不等于「很小」——这是最容易形成错误直觉的一条。一个 34 字符的不可断单词,它的 min-content 就是 272px;一张没写 max-width 的图片,它的 min-content 是图片的固有宽度。所以「我设了 min-width: 0 还是被撑开」通常不是这条规则失效,而是内容本身还有别的不可压缩项(图片、white-space: nowrap 的长串、<pre>)。同样常见的:
width: 100% 与 stretch 不等价。100% 参照包含块宽度但不扣自己的 margin,于是「宽 100% + 左右 margin」必然溢出——这是横向滚动条最常见的来源之一。第三个:
height: min-content 这类在块轴上的写法要小心,块轴的内在尺寸依赖内容排布,和宽度不是对称的;height 上更常见的正确工具是 min-height 与 flex/grid 的对齐,而不是内在尺寸关键字。width: 100%、stretch、1fr 时是容器说的;不写 width 而元素是 inline-block / 浮动 / 绝对定位 / flex 子项时,是内容说的。布局出乎意料时,先判断这一句,再决定去改容器还是改内容的约束——比逐个试参数快得多。DevTools 里也能直接验:把 width 临时改成 min-content 和 max-content 各看一次,两个数一出来,内容到底想要多宽就一目了然。「我给容器里放了一张 50px 高的图片,容器却是 56px 高,多出来的 6px 从哪来?」——这是行内格式化的经典现场。行内元素的高度不由它自己决定,而由「行盒」决定,而行盒的高度取决于字体度量。这一卡把这条链讲完。
那 6px 到底是什么
<img>默认是行内级元素,所以它站在一条基线上——基线不是文字的底边,而是字母x的下沿;- 基线之下还要给下伸部(
g、y的尾巴)留位置,这段空间由字体的 descent 决定。图片底边贴在基线上,那段 descent 就成了图片下面的空隙; - (16px 等宽字体、
line-height: 1.5、一张 50×50 的图):容器高 56px——图片 50px 加上 6px 的基线下方空间; - 三种消除方式,都得到 50px:给图片
vertical-align: middle(或bottom、top)、把图片改成display: block、或者把容器的line-height设成0; - 最省心的做法是全局给替换元素加
display: block(img, svg, video, canvas { display: block; max-width: 100% }是现代 reset 的标准条目)——它同时解决了这个空隙和响应式缩放两件事。
line-height: normal 不是 1.2
normal的具体值由字体文件里的 ascent / descent / lineGap 三个度量决定,不是规范里的固定倍数;- 同一个 16px 字号:本机等宽字体下
normal得到 18px(1.125 倍),无衬线字体下得到 24px(1.5 倍)。换字体、换机器、换语言的回退字体,这个数都会变; - 推论一:正文永远写具体的
line-height(无单位数字,如1.6),别依赖normal,否则「字体一回退,行距全变」; - 推论二:写无单位的数字而不是
px——无单位值是「相对自己字号的倍数」,子元素改字号时行高自动跟着走;写成24px则被子元素原样继承,大字号处会挤在一起。
vertical-align 管什么,不管什么
- 它只对行内级元素和表格单元格生效。写在块级元素上、写在 flex/grid 子项上完全无效且不报错——「垂直居中不生效」的经典误用第一名;
- 常用值:
baseline(默认)、middle(元素中线对齐基线上方 0.5ex 处,不是行盒中线,所以「middle 看起来没居中」是正常的)、top/bottom(对齐行盒的顶/底)、以及具体长度(相对基线偏移); - 要真的垂直居中,用 flex 的
align-items: center或 grid 的place-items: center(08 章)。vertical-align的正当用途只剩「图标和文字对齐」「表格单元格垂直对齐」这两类。
text-box-trim:把字体自带的空隙裁掉
- 上面那 6px 只是行盒问题的一种表现。更普遍的问题是:一段文字的盒子上下总有你没要的空隙,于是按钮里的文字看起来偏上、标题和上方的间距总比设计稿大;
text-box-trim: trim-both配text-box-edge: cap alphabetic的意思是「上边裁到大写字母顶、下边裁到基线」——盒子边界从此贴着实际字形;- (100px 字号、
line-height: 1.5的衬线字体):普通段落块高 150px,加上这两条后变成 72.91px——裁掉的正是行距与下伸部那一大块; - 必须写在块容器上:写在
<span>上(包括display: inline-block的 span)完全没有效果(仍是 150px),写在块上或用简写text-box: trim-both cap alphabetic才生效。这一条不很难发现。
/* 现代 reset 里最值钱的两行:顺手消掉基线空隙 */
img, svg, video, canvas, audio, iframe {
display: block;
max-width: 100%;
}
/* 正文行高:写无单位数字,别依赖 normal,也别写 px */
body { line-height: 1.6; } /* ✅ 子元素按自己字号换算 */
/* line-height: 24px ❌ 被原样继承,大字号处会挤 */
/* 图标和文字对齐:这才是 vertical-align 的正当用途 */
.icon { width: 1em; height: 1em; vertical-align: -.125em; }
/* 真正的垂直居中:交给 flex / grid */
.btn { display: inline-flex; align-items: center; gap: .5em; }
/* 让文字盒子贴着字形,间距终于所见即所得 */
h1, h2, .tight {
text-box: trim-both cap alphabetic; /* 简写;必须在块容器上 */
}<img> 后面的换行和空格删掉,间隙就没了」——这是另一个坑,别和基线空隙搞混。行内元素之间的空白符会被渲染成一个空格,所以横排的 inline-block 卡片之间会莫名多出 4~8px(宽度等于一个空格,随字体变)。它的解法是「让容器变成 flex / grid」或「容器 font-size: 0 再在子项上恢复」,而基线空隙的解法是 display: block / vertical-align——两个问题、两套解法,症状却都是「多了几像素」。此外还有一件:给行内元素写
height 没有用。<span> 上写 height: 40px 既不改变它占的行高,也不改变父容器高度(只有替换元素如 <img> 例外)。要有高度就先改 display。第三个:
line-height 参与继承,而行盒高度取所有行内盒的最大值。父容器行高设得再小,只要行里有一个字号大的 <span>,那一行就会变高——「为什么只有这一行间距变宽了」几乎都是这个原因。1em 尺寸配 vertical-align: -.125em 是行内图标的通用配方:图标随字号缩放,且视觉重心和文字对齐。想更省事就整体换成 display: inline-flex; align-items: center 的按钮/标签结构——一旦进了 flex 上下文,基线那套规则就不再参与,图标文字的对齐问题一次性消失。这也是「现代布局里很多人从没遇到过基线问题」的原因:他们的容器早就是 flex 了。CSS 里有两套方位词:物理的(top / right / bottom / left)和逻辑的(block-start / inline-end…)。逻辑那套是跟着文字方向走的——同一份样式表,在阿拉伯语的从右向左排版、或竖排的日文里自动镜像。今天写新样式应当默认用逻辑属性,它不比物理属性长,还少一类 bug。
两根轴、四个方位
- 行内轴(inline)=文字流动的方向。中英文里是水平,竖排里是垂直;
- 块轴(block)=一行接一行堆叠的方向。中英文里是垂直,竖排里是水平;
- 于是:
inline-start= 行首(LTR 里是左、RTL 里是右)、block-start= 第一行那一侧(通常是上); - 对照关系一句话记:
margin-inline是「左右」、margin-block是「上下」,前提是横排。
同一条声明在两种方向下的效果
margin-inline-start: 30px写在同一个块上:dir="ltr"时元素落在 x=30(margin 加在左边),dir="rtl"时元素落在 x=0、宽度仍是 170px(margin 加在了右边)——一条声明,两种方向各自正确;writing-mode: vertical-rl下写inline-size: 60px; block-size: 40px,得到的盒子是宽 40px、高 60px——两根轴真的交换了:inline-size 变成了竖直方向的尺寸;- 这就是逻辑属性的全部价值:你描述的是「沿文字方向多长」,而不是「水平多长」。
一张够用的对照表
| 物理写法 | 逻辑写法 | 备注 |
|---|---|---|
width / height | inline-size / block-size | 容器查询里用的正是这两个名字 |
margin-left / -right | margin-inline-start / -end | 两侧相同可缩写 margin-inline |
margin-top / -bottom | margin-block-start / -end | 缩写 margin-block |
padding-left 等 | padding-inline-start 等 | 规律完全一致 |
border-left 等 | border-inline-start 等 | 圆角是 border-start-start-radius |
top: 0; left: 0 | inset-block-start: 0; inset-inline-start: 0 | 四边相同就是 inset: 0 |
text-align: left | text-align: start | 这一条改起来成本最低、收益最直接 |
float: left | float: inline-start | 浮动也有逻辑值 |
overflow-x / -y | overflow-inline / -block | 较新,注意兼容 |
什么时候该继续用物理属性
- 真的和物理方向绑定的东西:
box-shadow的偏移(光源不会因为语言而镜像)、transform: translateX、装饰性的渐变角度; - 和滚动方向绑定的手势:横向滚动条、轮播的左右按钮——用户对「向右」的期待是物理的;
- 混用是可以的,但同一个属性别混:同时写
margin-left和margin-inline-start时后者在特异性相同的情况下按书写顺序决定胜负,这类冲突极难看出来。规则:同一个盒子的间距,要么全逻辑要么全物理。
/* 一次写好,LTR / RTL / 竖排都对 */
.card {
padding-inline: 1rem; /* 左右 */
padding-block: .75rem; /* 上下 */
border-inline-start: 3px solid var(--color-primary); /* 行首那道竖线 */
text-align: start;
}
/* 绝对定位也有逻辑版 */
.badge {
position: absolute;
inset-block-start: .5rem;
inset-inline-end: .5rem; /* RTL 下自动跑到左上角 */
}
/* 居中的逻辑写法 */
.wrap { margin-inline: auto; max-inline-size: 65ch; }
/* 竖排:两根轴交换,尺寸声明不用改 */
.vertical { writing-mode: vertical-rl; inline-size: 60px; block-size: 40px; }
<!-- HTML 侧:dir 和 lang 都要写对 -->
<!-- <html lang="ar" dir="rtl"> -->writing-mode 与 direction 走,而 direction 应当由 HTML 的 dir 属性来设,不是 CSS。只在 CSS 里写 direction: rtl 会让文本方向和 DOM 语义脱节:选中、复制、朗读顺序、以及浏览器的双向文本算法都依赖 dir,光改 CSS 得到的是「看着像 RTL 但用起来不对」。另一处:做 RTL 适配时最容易漏的不是 margin,而是图标方向和阴影。「返回」箭头、「下一页」箭头、缩进线在 RTL 下都该镜像,但它们通常是 SVG 或背景图,逻辑属性帮不上——需要
[dir="rtl"] .icon-back { scale: -1 1 } 这类显式规则。第三个:
vertical-align 这个名字里的 vertical 与逻辑轴无关,它管的一直是行内方向上的基线对齐;同理 overflow-x 在竖排下管的是块轴。凡是名字里带 x / y / horizontal / vertical 的属性,都要按物理轴理解,这是逻辑属性时代最容易混淆的一类命名。padding-inline: 1rem 一条顶 padding-left + padding-right 两条,margin-inline: auto 就是水平居中;② 和容器查询、inline-size 这些新特性用的是同一套词汇,学一次通用。Tailwind 里对应的是 ps-* / pe-*(inline-start / end)与 ms-* / me-*,而 pl-* / pr-* 仍是物理的——团队要统一选一套。07 章讲过 overflow 的常用值和副作用。这一卡补上最容易被忽略的一层:hidden 其实是一个「能被脚本滚动的滚动容器」,而 clip 才是真正的「切掉就完了」。这个差别决定了一类诡异 bug。
hidden 和 clip 差在哪
- 同一个 60px 宽的盒子里放 200px 宽的内容:两者看起来完全一样(都只显示 60px、都没有滚动条),
scrollWidth也都是 200; - 但用脚本设
scrollLeft = 50:overflow: hidden的盒子真的滚了(读回 50),overflow: clip的盒子没动(读回 0); - 这就解释了那个著名的诡异现象:
overflow: hidden的容器里,某个元素被聚焦(Tab 或focus())时,容器会「偷偷滚动」一下,内容整体偏移且再也回不去——因为浏览器为了让焦点元素可见,滚动了这个「隐藏的滚动容器」。一个overflow: hidden的 80px 宽容器,对里面靠后的按钮调一次focus(),scrollLeft就从 0 变成了 171; - 只想裁剪就用
clip,它不产生滚动容器,也就不会被滚动。
六个值各自的定位
| 值 | 裁剪 | 能滚动 | 建立 BFC | 典型用途 |
|---|---|---|---|---|
visible | 否 | 否 | 否 | 默认值 |
hidden | 是 | 脚本可以 | 是 | 历史上的万金油,副作用最多 |
clip | 是 | 否 | 是 | 纯裁剪的正确答案 |
scroll | 是 | 是(滚动条常驻) | 是 | 需要布局稳定的滚动区 |
auto | 是 | 是(按需出条) | 是 | 日常滚动区 |
overlay | — | — | — | 已废弃,别用 |
两个配套属性
overflow-clip-margin: 8px——配clip使用,允许内容溢出一点再裁。用来救「阴影 / 焦点环 / 徽章被裁掉一圈」这种场景,过去只能改成visible然后另想办法;scrollbar-gutter: stable——提前给滚动条留出槽位,于是「内容变长出现滚动条、整页横向跳一下」的抖动消失。写在:root上一劳永逸,配scrollbar-width: thin/scrollbar-color可以控制外观;- 还有两个方向类的:
overscroll-behavior: contain阻止「滚到底后带动整页滚动」(弹窗内滚动列表必用),scroll-snap-type做吸附式轮播。
横轴与纵轴不能各自独立取值
overflow-x和overflow-y可以分开写,但规范规定:一个方向是visible而另一个是裁剪值时,那个visible会被强制改成auto;- 后果很实际:想做「横向可滚、纵向不裁」的横向卡片列表,写
overflow-x: auto; overflow-y: visible是做不到的——纵轴会变成auto,于是卡片的 hover 放大、下拉菜单、阴影全被裁掉; - 解法只有两条:把需要溢出的东西移出滚动容器(浮层用锚点定位 +
fixed,见 07 章那张卡),或者给滚动容器留出足够 padding 让阴影有地方放; clip是例外:overflow-x: clip; overflow-y: visible是合法且真的能只裁一个方向的——这也是clip存在的主要理由之一。
/* 只想裁剪:用 clip,不要用 hidden */
.avatar { border-radius: 50%; overflow: clip; }
/* 裁剪但给阴影 / 焦点环留一点余地 */
.masked { overflow: clip; overflow-clip-margin: 8px; }
/* 只裁一个方向:clip 才做得到 */
.strip { overflow-x: clip; overflow-y: visible; }
/* 一劳永逸消掉滚动条引起的横向抖动 */
:root { scrollbar-gutter: stable; }
/* 弹窗里的滚动列表:别带动整页 */
.dialog-body {
overflow-y: auto;
overscroll-behavior: contain;
max-block-size: 60vh;
}
/* 横向吸附轮播 */
.carousel { display: flex; overflow-x: auto; scroll-snap-type: x mandatory; }
.carousel > * { flex: 0 0 80%; scroll-snap-align: center; }position: sticky 被中间层的 overflow 悄悄废掉,是这一族副作用里最常见的一条,而且结果正好又是 hidden 与 clip 的分水岭。同一个结构(外层 overflow-y: auto 滚动 → 中间层 → 内部 position: sticky; top: 0),把外层滚动 120px 后测 sticky 相对容器的位置:中间层 visible 粘住(y=0)、clip 也粘住(y=0)、而 hidden 与 auto 都失效(y=−120,跟着内容滚走了)。原因就是本卡开头那条:hidden / auto 会让中间层自己变成滚动容器,sticky 于是改粘那个不滚动的容器;clip 不产生滚动容器,所以不影响。排查方法:从 sticky 元素往上逐层看计算后的 overflow,找到第一个既不是 visible 也不是 clip 的祖先。还有一处:滚动容器结束边的
padding 不进可滚范围。一个 height: 60px; padding: 10px、内容 200px 高的容器:scrollHeight 是 220(两边 padding 都算进去了)、clientHeight 是 60,照理最大 scrollTop 该是 160,实际只能滚到 140——底部那 10px 白留了。需要底部留白就给最后一个子元素加 margin-block-end,别指望容器的 padding-bottom。第三个:
overflow: hidden 加在 <body> 上锁滚动,在移动端 Safari 上并不可靠,而且会丢失滚动位置。做弹窗优先用原生 <dialog>(自带滚动锁),需要手写时记录并恢复 scrollTop。overflow: hidden 当成「有副作用的历史遗留」来对待,每次写之前问一句「我要的是裁剪,还是要一个不显示滚动条的滚动区?」。要裁剪写 clip;要建 BFC(阻断外边距合并、包住浮动)写 display: flow-root;要锁住页面滚动优先用 <dialog> 的原生模态或 overflow: hidden 加 scrollbar-gutter: stable 补偿。把这三件事分开用三个工具,比一个 hidden 兼职三份工靠谱得多。同一个盒子上常常有三四条尺寸约束:aspect-ratio 想要 16:9,min-height 想要 200px,内容想要 300px,父容器只给 100px。它们不是平权的,有明确的优先顺序。知道这个顺序,就不会再靠反复删属性来试。
aspect-ratio 是「建议」不是「约束」
- 宽 200px 的盒子写
aspect-ratio: 16/9→ 200×113(16:9 精确成立); - 同时写
height: 50px→ 89×50。比例仍然被遵守,但换成了「由高推宽」——显式尺寸赢,比例负责推算另一边; - 写
aspect-ratio: 1但塞进十三行文字 → 200×312,比例被内容打破。因为块级元素的min-height默认是auto(不小于内容); - 补一句
overflow: hidden(或min-height: 0)→ 200×200,比例回来了,代价是内容被裁; - 结论的优先顺序:
min-*/max-*> 显式width/height>aspect-ratio> 内在尺寸。而「内容撑破比例」的机制是隐式的min-height: auto也属于第一档。
height: 100% 的链条必须一路完整
- 百分比高度参照的是包含块的高度,而包含块高度必须是「已确定的」。链上任何一环是
auto,后面全断; - 父 120px → 子
height: 100%→ 孙height: 100%,两者都拿到 120px;把中间那层的height去掉,子和孙都退化成 24px(内容高)——一环断,全链断; - 现代解法是根本不用百分比:父 120px 且
display: flex时,子元素不写任何高度就得到 120px(align-items默认stretch);display: grid同样是 120px; - 所以「让子元素等高父元素」的正确答案是把父元素变成 flex 或 grid,而不是一路往下写
height: 100%。这也顺手避开了「html, body { height: 100% }」那套老配方。
一张判断表
| 现象 | 最可能的原因 | 先试什么 |
|---|---|---|
| 比例莫名失效,盒子变高 | 内容超出,隐式 min-height: auto 生效 | min-height: 0 或 overflow: hidden |
写了 height: 100% 没反应 | 祖先某一环高度是 auto | 父元素改 flex / grid,删掉百分比 |
| 子项撑破容器 | 隐式最小尺寸 = min-content | min-width: 0 / minmax(0, 1fr) |
| 宽 100% 却出现横向滚动条 | 100% 不扣自己的 margin | 改 width: stretch 或用 padding |
max-width 好像被忽略 | 被 min-width 顶住(min 优先级更高) | 检查有没有更大的 min-width |
| 图片变形 | 只写了一个方向的尺寸 | aspect-ratio + object-fit: cover |
min-* 永远赢过 max-*
- 规范的解算顺序是:先按
width算,再用max-width夹一次,最后用min-width再夹一次——所以min-width: 300px; max-width: 200px的结果是 300px; - 这条在响应式里会咬人:给卡片写
min-width: 20rem是「防止太窄」的常见做法,但在 320px 的窄屏上它直接导致横向溢出,而max-width: 100%拦不住它; - 安全写法是把「防太窄」交给容器:
grid-template-columns: repeat(auto-fit, minmax(min(20rem, 100%), 1fr))——min(20rem, 100%)这一层就是专门为窄屏兜底的(11 章讲过这个组合)。
/* 比例 + 裁切:图片占位的标准写法 */
.thumb {
aspect-ratio: 16 / 9;
inline-size: 100%;
object-fit: cover; /* 图片自己裁,不变形 */
}
/* 比例不许被内容打破(代价是裁内容) */
.square { aspect-ratio: 1; min-height: 0; overflow: clip; }
/* ❌ 老配方:一路 100%,链一断就全断 */
/* html, body, #app, .page { height: 100% } */
/* ✅ 现代写法:父容器负责撑,子元素什么都不用写 */
.page {
min-block-size: 100dvh; /* dvh 而不是 vh,见 11 章 */
display: grid;
grid-template-rows: auto 1fr auto; /* 头 · 主体撑满 · 脚 */
}
/* 窄屏安全的自适应网格 */
.cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(20rem, 100%), 1fr));
gap: 1rem;
}aspect-ratio 在 flex 子项上的表现容易被误解,一下就清楚了:它不能凭空造出尺寸,只负责在两边之间换算。同一个空的 flex 子项写 aspect-ratio: 1,放在高 120px 的容器里:默认(align-items: stretch)120×120——交叉轴的高度由容器给,宽度由比例推出来,比例照样成立;改成 align-self: start 后塌成 0×0,因为既没有内容也没有高度,比例无从下手。所以「比例失效」多半是「两边都没有一个确定的尺寸」,该补的是一条尺寸(inline-size: 100% 之类),而不是去删 aspect-ratio。第二处:
aspect-ratio 用在 <img> 上时要和 object-fit 一起写。只写比例,图片会被拉变形;object-fit: cover 才是「按比例裁切」,contain 是「留白不裁」。第三个:百分比高度和百分比 padding 的参照物不同——
height: 50% 参照包含块的高度,而 padding-top: 50% 参照包含块的宽度(07 章提过)。这就是老式的 16:9 hack 能成立的原因,也是「莫名多出一大块纵向留白」的常见来源。第四个:
1fr 不等于 auto。1fr 是「分配剩余空间」,auto 是「按内容」;grid-template-rows: auto 1fr auto 里把中间那个写成 auto,主体就不会撑满,页脚也就不会贴底。grid-template-rows: auto 1fr auto 是「页脚永远贴底」这个经典需求的最短答案:父容器 min-height: 100dvh,三行分别给头、主体、脚,中间那行 1fr 把剩余空间全吃掉。它替掉了历史上所有的 sticky footer hack(负 margin、calc(100vh - 头高 - 脚高)、flex: 1 0 auto 三件套),而且头和脚的高度可以随内容变,不需要任何数字。判断一个布局方案是不是好方案,「有没有需要维护的魔法数字」是个很准的指标。速查:元素、选择器、属性与 Tailwind 反查
前面各章按主题讲透,这一章按查找方式重排同一批知识:知道标签名找用途、知道效果找属性、知道 Tailwind 类名找对应的 CSS、以及最实用的一张——知道「我想做什么」找该用什么。写代码时开着这一章,比翻前面十八章快。
HTML 有上百个元素,但日常写页面真正用到的是下面这些。选元素的唯一标准是语义——「它是什么」而不是「它长什么样」,长什么样交给 CSS。
页面结构与分组
| 元素 | 用途 | 要点 |
|---|---|---|
<header> <footer> | 页头 / 页脚 | 在 <article> 里也能用,表示该文章的头尾 |
<nav> | 导航区 | 一页可有多个,用 aria-label 区分 |
<main> | 主内容 | 每页只能一个,跳过导航的落点 |
<article> | 可独立成篇的内容 | 判据:单独摘出去仍然完整 |
<section> | 有主题的一段 | 必须带标题,否则用 <div> |
<aside> | 侧栏 / 补充 | 移走不影响主线 |
<h1>–<h6> | 标题层级 | 按嵌套深度选,不按字号选 |
<div> <span> | 无语义容器 | 纯为样式或脚本分组时才用 |
文本与行内语义
| 元素 | 含义 | 常见误用 |
|---|---|---|
<strong> / <b> | 重要 / 仅视觉加粗 | 大多数场合该用 <strong> |
<em> / <i> | 强调 / 仅视觉斜体 | 术语、外文用 <i> 才对 |
<code> <pre> | 代码 / 预格式化块 | 代码块是两者嵌套 |
<time datetime> | 机器可读时间 | 属性必写,正文可以是「三天前」 |
<abbr title> | 缩写 | 触屏拿不到 title,首次出现时写全称 |
<mark> | 高亮(与当前操作相关) | 搜索结果命中词的正确元素 |
<small> | 附注、免责声明 | 不是「字小一点」 |
<del> <ins> | 删除 / 插入 | 价格划线用 <s> 更准 |
<kbd> <samp> | 按键 / 程序输出 | 写文档很顺手 |
<wbr> | 可选换行点 | 长网址、长标识符里插 |
表单
| 元素 / 类型 | 用途 | 要点 |
|---|---|---|
<label for> | 关联标签 | 每个控件都必须有 |
<fieldset> <legend> | 一组相关控件 | 单选组的正确容器 |
type="email/tel/url" | 文本类专用键盘 | 移动端键盘会跟着变 |
type="number" | 数量 | 手机号、验证码别用它 |
inputmode="numeric" | 数字键盘但仍是文本 | 验证码的正确写法 |
type="search/date/color/range/file" | 各自的原生控件 | 样式受限但行为最可靠 |
<datalist> | 输入建议 | 零 JS 的自动补全 |
<output> | 计算结果 | 自带 role="status" 语义 |
<progress> <meter> | 进度 / 量值 | 后者表示「在范围内的读数」 |
autocomplete="…" | 自动填充提示 | 写对能大幅提升填写体验 |
媒体与交互
| 元素 / 属性 | 用途 | 要点 |
|---|---|---|
<picture> <source> | 按条件换图 | 换格式、换裁剪都靠它 |
srcset sizes | 同图多分辨率 | 浏览器自己挑 |
loading="lazy" | 懒加载 | 首屏大图别加 |
<video> <audio> | 音视频 | 自动播放必须 muted |
<dialog> | 模态 / 非模态对话框 | 必须用 showModal() 才是模态 |
popover popovertarget | 轻量浮层 | 零 JS 开合,自动进 top layer |
<details> <summary> | 折叠面板 | 同组加相同 name 即互斥 |
<template> | 不渲染的模板 | 克隆后插入 |
inert | 整片区域禁用 | 屏蔽弹窗背景 |
<table> 家族 | 二维数据 | <caption> + <th scope> 是无障碍关键 |
<!-- 一页的骨架:语义标签各就各位 -->
<body>
<a href="#main" class="skip">跳到主内容</a>
<header>
<nav aria-label="主导航">…</nav>
</header>
<main id="main">
<h1>页面标题</h1>
<article>
<h2>一篇文章</h2>
<p>发布于 <time datetime="2026-07-30">三天前</time></p>
</article>
<aside>相关阅读</aside>
</main>
<footer>…</footer>
</body><section> 不是「带语义的 div」——没有标题的 <section> 对读屏器毫无意义,还会污染地标结构;② type="number" 不能用来收手机号、验证码、银行卡号(会被当数字处理:前导零丢失、滚轮误改、部分浏览器加减按钮),正确写法是 type="text" 配 inputmode="numeric";③ <table> 只用于二维数据,不用于布局,但反过来——真的是数据表格时也别用 <div> 拼,那会让读屏器完全失去行列关系。<section> 带标题时会被念成「区域,某某标题」,不带标题就什么都不念(那就该用 <div>);<nav> 会被列进地标清单,所以页脚那三个链接不值得包一个 <nav>。这个标准比「哪个更语义化」具体得多,而且 Chrome DevTools 的 Accessibility 面板能直接看到结果。选择器的记忆负担主要来自「有太多长得像的」。按它们回答什么问题分组之后就好记了:选哪个元素、选第几个、选什么状态、选哪一部分。
组合与逻辑
| 写法 | 含义 | 特异性 |
|---|---|---|
A B | 后代(任意深度) | 两者相加 |
A > B | 直接子元素 | 两者相加 |
A + B | 紧邻的下一个兄弟 | 两者相加 |
A ~ B | 后面所有兄弟 | 两者相加 |
:is(a, b) | 命中任一 | 取括号内最高的 |
:where(a, b) | 命中任一 | 恒为 0,写默认样式必用 |
:not(a, b) | 都不命中 | 取括号内最高的 |
:has(a) | 包含符合条件的后代 | 取括号内最高的,可选父元素 |
& | 原生嵌套里的当前选择器 | 等价于 :is(…) |
结构伪类:选第几个
| 写法 | 含义 |
|---|---|
:first-child / :last-child | 父元素的第一个 / 最后一个子元素 |
:only-child | 独生子 |
:nth-child(2n) | 偶数位(odd / even 也可以) |
:nth-child(n + 4) | 第 4 个及以后 |
:nth-child(-n + 3) | 前 3 个 |
:nth-child(2 of .card) | 只在 .card 里数第 2 个 |
:nth-of-type(2) | 同标签名里数第 2 个 |
:nth-last-child(2) | 倒数第 2 个 |
:empty | 没有子节点(空格也算内容) |
:root | <html>,放全局变量的地方 |
状态伪类:选什么状态
| 写法 | 何时命中 | 备注 |
|---|---|---|
:hover / :active | 悬停 / 按下 | 触屏上 hover 行为不可靠 |
:focus | 获得焦点 | 鼠标点击也会触发 |
:focus-visible | 需要显示焦点环时 | 焦点样式一律用这个 |
:focus-within | 自己或后代有焦点 | 整块高亮 |
:disabled / :checked | 禁用 / 选中 | 配 :has() 可样式化整行 |
:invalid / :valid | 校验结果 | 页面一加载就成立 |
:user-invalid / :user-valid | 用户交互过之后的校验结果 | 做红框绿框用它 |
:placeholder-shown | 输入框为空 | 浮动标签效果 |
:target | URL 锚点指向它 | 纯 CSS 标签页 |
:popover-open / :open | 浮层 / details、select 已展开 | 写进出动画用 |
:defined | 自定义元素已注册 | 防止组件未加载时闪烁 |
伪元素:选哪一部分
| 写法 | 指向 |
|---|---|
::before / ::after | 元素内部首尾插入的框(必须有 content) |
::first-line / ::first-letter | 首行 / 首字(首字下沉) |
::selection | 用户选中的文字 |
::placeholder | 占位文字 |
::marker | 列表项的项目符号 |
::backdrop | <dialog> / 浮层背后的遮罩层 |
::details-content | <details> 展开的那部分 |
::picker(select) / ::picker-icon | 可定制下拉的列表与箭头 |
::view-transition-old(n) / -new(n) | 视图过渡的前后快照 |
::scroll-marker / ::scroll-button() | 滚动容器的原生指示点与翻页按钮 |
特异性:三个数字
- 写成
(ID, 类/属性/伪类, 元素/伪元素),从左往右比,前一位大就直接赢——不是加权求和,所以 11 个类也赢不过 1 个 ID; *、:where()、组合器本身都是(0,0,0);行内style高于任何选择器;!important另开一档;- 而层叠层(
@layer)比特异性更靠前判定——靠后的层无论特异性多低都赢,这是 12 章那张卡的全部要点。
/* 高频组合,抄下来就能用 */
/* 除第一个以外都加上间距(猫头鹰) */
.stack > * + * { margin-block-start: 1rem; }
/* 选父元素:有图片的卡片改成两列 */
.card:has(img) { display: grid; grid-template-columns: 6rem 1fr; }
/* 选中的单选项所在的整行 */
.option:has(:checked) { border-color: var(--color-primary); }
/* 只在前三项上做进场延迟 */
.list > li:nth-child(-n + 3) { animation-delay: .1s; }
/* 零特异性的默认样式:使用者一个类就能覆盖 */
:where(h1, h2, h3) { margin-block: 0 .5em; }
/* 焦点环:永远用 focus-visible,永远别只写 outline: none */
:focus-visible { outline: 2px solid var(--color-primary); outline-offset: 2px; }
/* 输入框为空时把标签浮回去 */
.field:has(input:placeholder-shown) label { translate: 0 1.4rem; opacity: .6; }:nth-child() 数的是「所有兄弟」,不管标签是什么——p:nth-child(2) 的意思是「它是父元素的第二个孩子且是 <p>」,如果第二个孩子是 <h2> 就什么都选不到;想按类型数要用 :nth-of-type(),想按类数要用 :nth-child(2 of .card)。② :empty 严格到连空格都算内容,模板里换行缩进出来的元素几乎永远不 :empty。③ ::before / ::after 不写 content 就完全不生成,而生成出来的内容会被读屏器念出来——用它加装饰图标时要么用 content: "" 配背景图,要么补 content: "" / ""(斜杠后是替代文本,留空即不念)。:has() 把 CSS 从「只能往下选」变成「能往上和往旁边选」,它替掉的是过去大量必须写 JS 加类名的场景。三个最值钱的用法:① 按内容改布局(.card:has(img))、② 按子元素状态改容器(.row:has(:checked)、form:has(:user-invalid))、③ 按后续兄弟反过来改前面的元素(label:has(+ input:focus))。写的时候记住它不参与特异性叠加,只取括号内最高的那一项。这一卡是「知道想要什么效果、忘了属性叫什么」时用的。按结果分组,不按属性名字母序排——后者是 MDN 的活,本页要做的是帮你想起该查哪个词。
布局
| 想要的效果 | 属性 / 值 |
|---|---|
| 一维排布(工具条、按钮组) | display: flex + gap + justify-content / align-items |
| 二维排布(页面骨架、卡片墙) | display: grid + grid-template-columns / -areas |
| 响应式卡片墙,不写断点 | repeat(auto-fit, minmax(min(20rem, 100%), 1fr)) |
| 任意元素居中 | display: grid; place-items: center |
| 页脚贴底 | min-block-size: 100dvh + grid-template-rows: auto 1fr auto |
| 子网格对齐父轨道 | grid-template-rows: subgrid + grid-row: span N |
| 元素脱离常规流 | position: absolute(参照最近的已定位祖先) |
| 滚动时吸顶 | position: sticky + top: 0(祖先不能有 overflow: hidden/auto) |
| 浮层贴着触发器 | anchor-name + position-anchor + position-area |
| 按内容宽度 | width: fit-content |
| 撑满且为 margin 让位 | width: stretch |
| 防止子项撑破容器 | min-width: 0 / minmax(0, 1fr) |
| 组件按容器而非视口响应 | container-type: inline-size + @container |
盒子与视觉
| 想要的效果 | 属性 / 值 |
|---|---|
| 尺寸所见即所得 | box-sizing: border-box |
| 纯裁剪(不产生滚动容器) | overflow: clip(+ overflow-clip-margin) |
| 阻断外边距合并 / 包住浮动 | display: flow-root |
| 把堆叠关进组件内部 | isolation: isolate |
| 固定宽高比 | aspect-ratio + object-fit: cover |
| 圆角 / 非圆的圆角 | border-radius + corner-shape: squircle |
| 内阴影 / 文字阴影 | box-shadow: inset … / text-shadow |
| 毛玻璃 | backdrop-filter: blur()(会创建层叠上下文) |
| 渐变边框 | border: 1px solid transparent + background-origin/clip,或 border-image |
| 只在深色模式换值 | light-dark(浅, 深) + color-scheme |
| 让浏览器选黑白前景色 | contrast-color() |
| 裁成任意形状 | clip-path / shape() / mask |
文字
| 想要的效果 | 属性 / 值 |
|---|---|
| 标题各行等长 | text-wrap: balance |
| 正文避免孤字 | text-wrap: pretty |
| 长网址不溢出 | overflow-wrap: anywhere |
| 单行截断 | white-space: nowrap + overflow: hidden + text-overflow: ellipsis |
| 多行截断 | -webkit-line-clamp 四件套 |
| 文字盒子贴着字形 | text-box: trim-both cap alphabetic(写在块上) |
| 表格数字对齐 | font-variant-numeric: tabular-nums |
| 字体加载不闪不消失 | font-display: swap + preload + size-adjust |
| 视口自适应字号 | clamp(1rem, 2.5vw + .5rem, 2rem) |
| 好看的下划线 | text-decoration-thickness + text-underline-offset(后者不在简写里) |
| 全大写 / 首字母大写 | text-transform: uppercase / capitalize(对中文无效,会写坏 iPhone) |
| 中文首行缩进 | text-indent: 2em(会继承,嵌套块要归零) |
| 块级子元素居中 | margin-inline: auto(不是 text-align: center) |
| 两端对齐连末行 | text-align: justify + text-align-last: justify |
| 去掉列表圆点 | list-style: none + HTML 上补 role="list" |
交互与控件外观
| 想要的效果 | 属性 / 值 |
|---|---|
| 按钮显示手型光标 | cursor: pointer(浏览器默认给的是 default) |
| 禁用态的光标 | :disabled { cursor: not-allowed }(同样不会自动给) |
| 点击穿透到下层 | pointer-events: none(挡鼠标不挡键盘,不是禁用) |
| 防止双击选中一片蓝 | user-select: none(只用于按钮/把手/行号) |
| 真正禁用 | disabled 属性 / inert(HTML 层面,不是 CSS) |
| 勾选框、滑块上品牌色 | accent-color(一行,别再 appearance: none 重造) |
| 输入光标颜色 / 隐藏 | caret-color(transparent 即隐藏) |
| 输入框按内容伸缩 | field-sizing: content |
| textarea 只让竖向拉 | resize: vertical(默认是 both) |
| 普通元素可拖拽改尺寸 | resize + overflow 必须非 visible |
| 细滚动条 / 配色 | scrollbar-width: thin + scrollbar-color |
| 滚动条不挤动布局 | scrollbar-gutter: stable |
| 移动端只要滚动和点击 | touch-action: manipulation |
动效
| 想要的效果 | 属性 / 值 |
|---|---|
| 状态变化的过渡 | transition(只动 transform / opacity 最省) |
过渡 display、能淡出再消失 | transition-behavior: allow-discrete |
| 元素刚出现就有进入动画 | @starting-style |
过渡到 height: auto | interpolate-size: allow-keywords / calc-size() |
| 滚动进度条 / 进场动画 | animation-timeline: scroll() / view() + animation-range |
| 一次 DOM 更新变成动画 | document.startViewTransition() + view-transition-name |
| 尊重系统设置 | @media (prefers-reduced-motion: reduce) |
| 自定义属性也能动画 | @property 声明 syntax 与 initial-value |
函数与单位
| 写法 | 用途 | 要点 |
|---|---|---|
var(--x, 兜底) | 取自定义属性 | 兜底值是逗号后的全部内容 |
calc() | 混单位运算 | 加减号两侧必须有空格 |
min() / max() / clamp() | 取值范围 | clamp(最小, 理想, 最大) |
oklch() | 感知均匀的颜色 | 调亮度不串色,是今天的默认选择 |
color-mix(in oklab, a, b 30%) | 混色 | 做 hover 态、生成色阶 |
oklch(from var(--c) l c h / .5) | 相对颜色 | 基于已有颜色改某一维 |
rem / em | 相对根 / 相对自身字号 | 间距用 rem,随字号缩放的用 em |
ch / lh | 字符宽 / 一个行高 | 输入框宽度、几行高 |
dvh / svh / lvh | 动态 / 最小 / 最大视口高 | 移动端别再用 vh |
cqw / cqi | 容器查询单位 | 相对容器而非视口 |
% | 百分比 | 纵向 padding 参照的是宽度 |
/* 十条最常抄的现代写法 */
*, *::before, *::after { box-sizing: border-box; }
:root {
color-scheme: light dark;
scrollbar-gutter: stable;
--brand: oklch(.62 .21 260);
--brand-hover: color-mix(in oklab, var(--brand), black 12%);
}
.container { inline-size: min(100% - 2rem, 72rem); margin-inline: auto; }
.cards { display: grid; gap: 1rem;
grid-template-columns: repeat(auto-fit, minmax(min(20rem, 100%), 1fr)); }
h1 { font-size: clamp(1.75rem, 4vw + 1rem, 3rem); text-wrap: balance; }
.center { display: grid; place-items: center; }
.truncate { min-width: 0; white-space: nowrap;
overflow: hidden; text-overflow: ellipsis; }
.card { container-type: inline-size; isolation: isolate; }
@container (inline-size > 28rem) { .card { grid-template-columns: 8rem 1fr; } }
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation-duration: .01ms !important; transition-duration: .01ms !important; }
}calc() 里的加号和减号两侧必须有空格(calc(100% -2rem) 会被解析成「100% 和 −2rem 两个值」而整条失效),乘除号不需要——这是 CSS 里最反直觉的语法规则之一,而且不报错,只是那条声明被丢弃。顺带一处:
var() 的兜底值是逗号之后的全部内容,所以 var(--shadow, 0 1px 2px, red) 的兜底值是「0 1px 2px, red」这一整串;想给多值属性写兜底要小心。第三个:自定义属性的值在被使用前几乎不做校验。
--gap: 10pxx 这种拼错的值在声明时不报错,直到 gap: var(--gap) 那一刻整条声明才失效——而 DevTools 里那行看起来是「已生效」的。这类问题查起来最费时间,写变量时不妨用 @property 声明类型,声明了类型的变量在赋错值时会回退到 initial-value 而不是静默污染。clamp() 的中间那一项写成 视口单位 + 固定值(如 4vw + 1rem)而不是纯 vw,理由是纯 vw 会让「用户放大字号」完全失效——加上一个 rem 项之后,缩放仍然有效果。这条同样适用于 min() / max()。凡是把可访问性和响应式混在一起的地方,「纯视口单位」几乎总是错的选择。读别人的 Tailwind 代码时最费劲的是「这个类到底生成了什么」。本卡的每一行都是用项目里的 Tailwind 4.3.2 真编译出来的产物(调 compile() 传入候选类名再读输出),不是凭记忆写的对照表。
尺寸与间距:全部来自令牌
| 类名 | 生成的 CSS |
|---|---|
p-4 | padding: calc(var(--spacing) * 4)(--spacing 默认 0.25rem,所以是 1rem) |
size-8 | width 与 height 同时设为 calc(var(--spacing) * 8) |
h-dvh / h-screen | height: 100dvh / height: 100vh |
max-w-prose | max-width: 65ch |
min-w-0 | min-width: 0(18 章那条隐式最小尺寸的解药) |
grid-cols-3 | grid-template-columns: repeat(3, minmax(0, 1fr))——Tailwind 默认替你写了 minmax(0, …) |
space-x-4 | :where(& > :not(:last-child)) 里设 margin,是选择器而不是 gap |
aspect-video | aspect-ratio: var(--aspect-video)(默认 16 / 9) |
常用工具类的真实展开
| 类名 | 生成的 CSS(要点) |
|---|---|
truncate | overflow: hidden; text-overflow: ellipsis; white-space: nowrap |
line-clamp-3 | display: -webkit-box 那一整套四条 |
sr-only | position: absolute; width: 1px; … 视觉隐藏但读屏器可读 |
bg-black/50 | background-color: color-mix(in srgb, #000 50%, transparent)——v4 用 color-mix 而不是 rgba |
shadow / ring-2 | 都写进同一条 box-shadow,靠 --tw-shadow / --tw-ring-shadow 变量分层叠加 |
transition | 属性列表里除了颜色变换,还包含 display、content-visibility、overlay、pointer-events——正是为浮层进出场准备的 |
field-sizing-content | field-sizing: content(04 章那条新特性已有工具类) |
scheme-light-dark | color-scheme: light dark |
isolate | isolation: isolate |
wrap-break-word | overflow-wrap: break-word |
变体展开:冒号前面那部分
| 写法 | 展开成 |
|---|---|
md:flex | @media (width >= 48rem)——v4 用范围语法 |
max-md:hidden | @media (width < 48rem) |
@md:flex | @container (width >= 28rem)——容器查询变体 |
hover:bg-gray-100 | &:hover 且外面套了 @media (hover: hover),触屏上不会误触发 |
group-hover:opacity-100 | &:is(:where(.group):hover *),同样套 @media (hover: hover) |
peer-checked:bg-blue-500 | &:is(:where(.peer):checked ~ *) |
has-[:checked]:bg-blue-50 | &:has(*:is(:checked)) |
*:p-2 | :is(& > *)——直接子元素 |
in-[.sidebar]:pl-4 | :where(*:is(.sidebar)) &——按祖先选 |
nth-3:font-bold | &:nth-child(3) |
starting:opacity-0 | @starting-style { opacity: 0% } |
supports-[display:grid]:grid | @supports (display:grid) |
任意值:方括号与圆括号
| 写法 | 生成 | 用途 |
|---|---|---|
top-[117px] | top: 117px | 任意值 |
bg-[oklch(0.7_0.1_200)] | background-color: oklch(0.7 0.1 200) | 空格写成下划线 |
[grid-template-columns:1fr_auto] | grid-template-columns: 1fr auto | 任意属性 |
text-(length:--fs) | font-size: var(--fs) | 取自己的变量(圆括号写法) |
/* v4 的配置就是 CSS:@theme 里定义令牌,工具类自动派生 */
@import "tailwindcss";
@theme {
--color-brand: oklch(.62 .21 260); /* → bg-brand / text-brand / border-brand */
--spacing: .25rem; /* → p-4 = calc(.25rem * 4) */
--font-display: "Inter", sans-serif; /* → font-display */
--breakpoint-3xl: 120rem; /* → 3xl:flex */
}
/* 自定义变体:一次定义,处处可用 */
@custom-variant theme-dark (&:where([data-theme="dark"] *));
/* 组件类还是可以写,但优先用 @layer 归位 */
@layer components {
.btn { @apply inline-flex items-center gap-2 rounded-lg px-4 py-2; }
}
// 条件类的标准写法(cn = clsx + tailwind-merge)
// class={cn("rounded px-3 py-1", isActive && "bg-brand text-white", className)}space-x-4 与 gap-4 不是同一件事,这是从产物里才看得清的差别:gap-4 是容器的 gap,而 space-x-4 生成的是 :where(& > :not(:last-child)) 的 margin——它依赖「谁是最后一个子元素」,遇到 flex-wrap 换行、order 改序、或者中间插入条件渲染的元素时就会错。能用 gap-* 就别用 space-*(16 章那张卡专门讲这个取舍)。另一个容易翻车的地方:
hover: 系列被包在 @media (hover: hover) 里。这是好事(触屏上不再有「点一下就一直保持 hover 态」),但意味着你在开发者工具里模拟移动设备时,所有 hover 样式都不会生效——不是写错了。第三个:任意值里的空格要写成下划线(
bg-[oklch(0.7_0.1_200)]),而真正需要下划线的地方要转义。这条不知道的话,写含空格的任意值会静默不生成——Tailwind 对不认识的类是「不生成」而不是「报错」,和 CSS 的静默失效是同一种痛。@import "tailwindcss"; 的 CSS,配一个只含那些类名的 HTML,跑一次构建,输出里就是准确答案(本卡的表就是这么来的)。这比查文档快,也比问人可靠——尤其在 v3 与 v4 混杂的教程之间,产物是唯一不会过时的说明书。最后一张表按「需求」检索。它同时是一份「别再用老办法」的清单——右边那一列如果和你现在的写法不一样,多半是因为平台在这几年补上了原生能力。
布局与定位
| 我想… | 当代答案 | 该看哪一章 |
|---|---|---|
| 水平垂直居中 | display: grid; place-items: center | 08 |
| 页脚永远贴底 | 100dvh + grid-template-rows: auto 1fr auto | 18 |
| 响应式卡片墙不写断点 | repeat(auto-fit, minmax(min(20rem, 100%), 1fr)) | 08、11 |
| 组件在窄侧栏里换布局 | 容器查询 @container | 11 |
| 卡片内三行跨卡对齐 | subgrid | 08 |
| 浮层贴着按钮且自动翻面 | 锚点定位 + popover | 07 |
| 吸顶表头 | position: sticky(查祖先 overflow) | 07、18 |
| 等高列 | flex / grid 默认 stretch,别写 height: 100% | 18 |
| 清除浮动 / 阻断外边距合并 | display: flow-root | 07 |
交互与组件
| 我想… | 当代答案 | 该看哪一章 |
|---|---|---|
| 折叠面板 | <details><summary>(同组 name 即互斥) | 03 |
| 模态对话框 | <dialog> + showModal() | 17 |
| 轻量浮层 / 菜单 | popover + popovertarget | 17 |
| 下拉菜单要能改样式 | appearance: base-select + ::picker(select) | 04 |
| 输入框跟着内容变宽 | field-sizing: content + min/max-width | 04 |
| 校验提示别一进页面就红 | :user-invalid | 04 |
| 屏蔽弹窗背后的一切 | inert | 04 |
| 可搜索下拉 / 日期选择器 | 用无头库,别自己写 | 17 |
| 焦点环好看又不伤可用性 | :focus-visible + outline-offset | 04、06 |
视觉与动效
| 我想… | 当代答案 | 该看哪一章 |
|---|---|---|
| 暗色模式 | color-scheme + light-dark() + data-theme 手动开关 | 09、16 |
| 一套色板生成深浅色阶 | oklch() + color-mix() | 09 |
| 标题不留孤字 | text-wrap: balance | 09 |
| 按钮里的文字真正居中 | text-box: trim-both cap alphabetic | 18 |
| 展开动画(高度 auto) | interpolate-size: allow-keywords | 10 |
| 弹窗淡入淡出(纯 CSS) | @starting-style + allow-discrete | 10 |
| 阅读进度条 | animation-timeline: scroll(root block) | 10 |
| 页面间的平滑转场 | @view-transition { navigation: auto } | 10 |
| 动效不打扰敏感用户 | @media (prefers-reduced-motion) | 10 |
工程与排查
| 我想… | 当代答案 | 该看哪一章 |
|---|---|---|
| 样式不再打架 | @layer 定层序 + :where() 归零特异性 | 05、12 |
| 组件样式不外泄 | @scope / CSS Modules / Shadow DOM | 12 |
| 主题令牌 | 自定义属性 + @property 声明类型 | 12 |
| 样式改了没反应 | 看层叠层 → 特异性 → 有没有拼错值(会静默失效) | 01、05 |
浮层被裁掉 / z-index 无效 | 往上找 overflow / transform / filter / contain | 07 |
| 布局被长内容撑破 | min-width: 0 / minmax(0, 1fr) | 08、18 |
| 页面加载时内容跳动(CLS) | 图片写 width/height 或 aspect-ratio、字体 size-adjust | 04、12 |
| 首屏变快 | 关键 CSS 内联、字体 preload、非首屏图 loading="lazy" | 12 |
| 验证无障碍 | 拔掉鼠标用 Tab 走一遍 + DevTools 的 Accessibility 面板 | 04、17 |
/* 最常被问的五个需求,各一段可直接粘的代码 */
/* 居中 */
.center { display: grid; place-items: center; }
/* 页脚贴底 */
.app { min-block-size: 100dvh; display: grid; grid-template-rows: auto 1fr auto; }
/* 自适应卡片墙 */
.cards { display: grid; gap: 1rem;
grid-template-columns: repeat(auto-fit, minmax(min(20rem, 100%), 1fr)); }
/* 暗色模式:一行声明 + 一处覆盖 */
:root { color-scheme: light dark; --bg: light-dark(#fff, #14161a); }
[data-theme="dark"] { color-scheme: dark; }
[data-theme="light"] { color-scheme: light; }
/* 单行截断(在 flex 里必须带 min-width: 0) */
.title { min-width: 0; white-space: nowrap;
overflow: hidden; text-overflow: ellipsis; }<dialog> 不做滚动锁和退场动画、<details> 的动画需要 ::details-content 配合,这些细节 17 章讲过,别以为换成原生元素就万事大吉;③ 本页所有数字都是本机 Chrome 150 上量的,用来说明机制可以,当成跨浏览器的结论就错了——真正该记住的是每一条背后的判据,而不是那个数。float 布局、height: 100% 链、@media 断点里逐个覆盖组件样式、rgba() 手写色阶、max-height 猜展开高度、或者为了浮层维护一张 z-index 表——每一条都能在右列找到成本更低的替代品。迁移不必一次做完,从「新代码只用新写法」开始就够了。从这里到精通:路线图
地图铺完了,剩下的路要亲手写出来。最后这一章给出收尾路线:难度递进的动手项目、按阶段的资料,以及一条自测标准。
Web UI 是一门手感型技艺:概念都懂和「一小时还原一张设计稿」之间隔着几十个真实页面。按下面的顺序走,每一步都有明确产出物。
动手项目(难度递进)
- ① 像素级复刻:只用语义 HTML + 原生 CSS,复刻一个知名站点首页(Stripe、GitHub 首页都是好靶子),产出与原站肉眼难辨的静态单页——把盒模型、间距、字体排印的手感练出来(07、09 章);
- ② 响应式重构:用 Grid + 容器查询重做它,320px 到 1440px 全程不破版,同一张卡片组件在侧栏和主栏两种容器里自动切换布局,产出多断点截图对比(08、11 章);
- ③ 组件库小样:做 10 个基础组件(按钮/输入框/对话框/卡片…),用 CSS 变量做设计令牌,实现
prefers-color-scheme+data-theme双策略的主题切换与暗色模式,产出一个可交互的组件展示页(12、16 章); - ④ 双 90+ 收官:把一个完整页面推过 Lighthouse 无障碍与性能双 90 分——键盘可达、对比度达标、图片懒加载、字体预加载,产出优化前后的报告对比(04、12 章)。
书与资料(按阶段)
- 随手查阅:MDN(HTML/CSS 最权威参考,拿不准就查它);
- 系统学习:web.dev(Google 出品,Learn HTML/Learn CSS/Learn Accessibility 三个系列免费成体系);
- 布局专项:《Every Layout》(Heydon Pickering & Andy Bell)——用组合思维写出不依赖断点的弹性布局;
- 日常跟进:CSS-Tricks(经典 Flexbox/Grid 速查图)、年度 State of CSS 调查看趋势。
怎么判断自己卡住了
- 还在靠反复试参数调布局,而不是先说得出「这里该 Flex 还是 Grid、谁是包含块」——回 07 和 08 章;
- 样式改了没反应就加
!important——回 05 的层叠卡和 12 的@layer卡; - 项目大了之后不敢删任何一行 CSS——这是作用域问题,看 12 章的作用域化谱系。
index.html 里跑——不动手,本页 90% 的内容会在两周内忘光。另一个陷阱是只做视觉:无障碍和性能不是收尾的可选项,它们改的是结构,等页面写完再补往往要推倒重来(键盘顺序依赖 DOM 顺序、防 CLS 依赖一开始就写尺寸)。