普通选择器帮我们找到某个元素,伪类和伪元素则把问题再往前推一步:这个元素现在是什么状态,它在兄弟节点中排第几,我们想装饰的又是元素的哪一部分。很多界面反馈其实已经由浏览器记录好了。选对伪类或伪元素,CSS 就能直接读取这些信息,不必再加一层类名或 JavaScript。

伪类以单冒号开头,例如 :hover;现代 CSS 中的伪元素通常写成双冒号,例如 ::before。先记住一个区别就够了:伪类选中的仍是真实元素,伪元素表示这个元素中可单独排版的局部,或由 CSS 生成的盒子。
伪类描述的对象,就是它紧挨着的元素。li:first-child 的意思是“这个 li 恰好是父元素的第一个子元素”,并不是“找到 li 里面的第一个孩子”。如果要选列表的第一个直接子元素,应写成 ul > :first-child。读选择器时,可以先把伪类和左边的元素连起来读,方向会清楚很多。
同一个元素后面可以连接多个伪类,表示这些条件要同时成立:
a:link:hover { color: #dc2626; }
a:visited:hover { color: #7f1d1d; }
button:enabled:focus-visible { outline: 3px solid #2563eb; }这类链式条件通常不依赖书写顺序,但互相矛盾的状态永远不会匹配。链接不能同时是 :link 和 :visited,控件也不能同时是 :enabled 和 :disabled。拿不准时,把选择器翻译成一句话,矛盾往往马上就能看出来。
:root 表示文档的根元素。在 HTML 里,它通常就是 html;不过 :root 也适用于其他标记语言,而且两者的选择器优先级不同。我们常把全局自定义属性放在这里:
:root {
--brand: #2563eb;
--space: 1rem;
}:empty 只匹配完全没有子节点的元素。一个空格或换行也会形成文本节点,因此看起来空白的元素未必能匹配;注释则不算内容。*:empty { display: none; } 看似省事,实际很容易误伤,因为 img、input、br、hr 这类元素本来就没有子节点。
:only-child 要求当前元素是父元素唯一的元素子节点,旁边的纯文本不参与计数。:only-of-type 的范围更窄:它只要求当前元素是兄弟节点中唯一的同标签元素。a > img:only-child 比 a img:only-child 多了一层限制,图片还必须是链接的直接子元素。
<p></p> <!-- 匹配 :empty -->
<p> </p> <!-- 不匹配:含空格文本节点 -->
<p><!--提示--></p><!-- 匹配:只有注释 -->:first-child 和 :last-child 会在全部元素兄弟中判断首尾。:first-of-type 和 :last-of-type 则只看相同标签的兄弟。混合标签最能说明区别:如果一行以 th 开头,后面的第一个 td 不是 :first-child,但它确实是 td:first-of-type。
同样的区别也出现在 nth 系列中。:nth-child() 和 :nth-last-child() 从前或从后给全部元素兄弟编号;:nth-of-type() 和 :nth-last-of-type() 只给同类型兄弟编号。这里的位置从 1 开始。odd 等于 2n+1,even 等于 2n,3n+2 会得到 2、5、8……,-n+4 则表示前四个。现代浏览器还支持在 :nth-child() 中写 of <selector-list>,先筛选元素,再对结果计数。
.grid > :nth-child(3n + 2) { transform: translateY(8px); }
li:nth-last-child(-n + 2) { border-bottom: 0; }
.feed > :nth-child(odd of :not([hidden])) { background: #f8fafc; }
:link 匹配尚未访问的链接,:visited 匹配已访问链接,:any-link 则包括两者。只写 a 还会选中没有 href 的占位锚点,a:any-link 才只指向真正的链接。浏览器会限制 :visited 能改变的属性,主要允许颜色相关属性;脚本读取计算样式时,也拿不到足以识别访问历史的真实差异。这些限制用于防止网站探测浏览记录,但我们仍可以用颜色帮助读者分辨已经看过的链接。
:target 匹配 URL 片段指向的元素。比如地址以 #install 结尾,页面中 id="install" 的元素就会成为目标。目录定位高亮、步骤跳转等效果都可以由它完成。:target-within 还希望让目标元素的祖先一起匹配,不过在项目中使用前要先核查兼容性。
:scope 表示当前的选择上下文。在普通样式表里,它通常对应作用域根;在 JavaScript 查询中,它的用途更直观:card.querySelectorAll(':scope > .item') 只查找 card 的直接子项。还要留意,querySelectorAll() 返回的是静态集合,DOM 后来新增的节点不会自动加入已经取得的结果。
a:any-link { text-underline-offset: .2em; }
a:visited { color: #6d28d9; }
section:target { outline: 3px solid #f59e0b; }:hover 表示指针悬停,:active 表示元素正在被激活的短暂阶段,:focus 表示元素拥有输入焦点。只写 :hover 会漏掉不少用户:触屏设备没有稳定的悬停状态,键盘操作则依赖焦点。链接样式通常可以按 :link、:visited、:focus-visible、:hover、:active 的顺序组织,让后面的具体反馈覆盖前面的基础状态。
:focus-visible 会在浏览器判断需要显示焦点提示时匹配。它通常能为键盘导航保留清楚的轮廓,又不会让每次鼠标点击都出现相同效果。:focus-within 会在元素自身或任一后代获得焦点时匹配,很适合高亮整组表单字段。不要直接用 outline: none 抹掉焦点;如果确实要换样式,新的提示必须同样清楚。
.field:focus-within { border-color: #2563eb; }
button:focus-visible { outline: 3px solid #f59e0b; outline-offset: 3px; }
button:active { transform: translateY(1px); }:enabled 和 :disabled 只适用于支持启用、禁用语义的界面控件。给普通 div 随手加上 disabled 属性,并不会让它匹配 :disabled。:checked 匹配已选中的复选框、单选按钮和选项。:indeterminate 表示不确定态,比如脚本把复选框的 indeterminate 属性设为 true,或者一组同名单选按钮还没有任何选择。
:default 指由 HTML 标记指定的默认项,它和“现在是否选中”是两件事。用户操作后,默认项可能已经不再匹配 :checked,但它仍然是 :default。:autofill 用于浏览器自动填充的字段,不过浏览器出于安全和一致性考虑,可能保留一部分内置样式。
input:disabled { opacity: .55; cursor: not-allowed; }
input:checked + label { font-weight: 700; }
input:indeterminate + label::after { content: "(部分选择)"; }:required 和 :optional 反映控件是否必须填写。HTML 布尔属性只看属性是否存在,所以 required="false" 仍然表示必填;字符串 false 并不能取消它。:valid 和 :invalid 会综合检查 type、required、pattern、minlength 等约束。一个非必填的邮箱输入框为空时通常有效,因为它允许不填;输入了错误格式后,才会变成无效状态。
:in-range 和 :out-of-range 只判断值是否落在 min 与 max 之间,范围内并不等于整体有效。min="0" max="100" step="10" value="23" 会同时匹配 :in-range 和 :invalid:23 没有越界,但不符合步长。:read-write 匹配用户可编辑的输入或 contenteditable 元素。很多其他元素都会匹配 :read-only,因此实际写规则时,最好再加上元素类型或类名来缩小范围。

:lang() 根据元素实际使用的语言来匹配。语言可以从祖先的 lang 继承,也可能来自文档或协议层信息,所以它和只检查当前元素有没有 [lang] 属性并不相同。:lang(zh) 还能覆盖 zh-CN、zh-TW 等带子标签的中文标记。
:dir(ltr) 和 :dir(rtl) 根据 HTML 的方向性匹配。方向可能来自 dir 属性、继承规则或双向文本算法,并不是简单读取 CSS 的 direction 属性。处理多语言界面时,最好同时使用 margin-inline-start 这类逻辑属性,不要把“开始侧”固定写成 left 或 right。
:lang(zh) q { quotes: "“" "”" "‘" "’"; }
:lang(fr) { hyphens: auto; }
:dir(rtl) .icon-next { transform: scaleX(-1); }:not() 用来排除元素,参数可以是一组选择器,例如 .menu a:not(.current, [aria-disabled="true"])。关键还是看它否定了谁:div:not(.one) p 只要求段落存在一个不带 .one 的 div 祖先。即使这个段落同时位于另一个 .one 中,规则依然可能匹配。DOM 结构复杂时,要把范围锚定得更准确。
:is() 和 :where() 都能把一组选项放进复杂选择器,并使用宽容列表:某个分支无效时,其他有效分支仍可工作。两者真正需要区分的是优先级。:is() 采用参数中最高的优先级,:where() 自身的优先级始终为零。组件基础样式常用 :where(),方便外部覆盖;需要保留参数权重时再用 :is()。
:defined 匹配浏览器已经认识的标准元素,以及注册成功的自定义元素。如果 Web Component 还没注册,可以用 my-card:not(:defined) 暂时隐藏内容或显示骨架,等注册完成后再呈现组件。
:where(article, aside) :is(h2, h3) { scroll-margin-top: 5rem; }
nav a:not([aria-current="page"]) { opacity: .72; }
user-card:not(:defined) { visibility: hidden; }:has() 根据元素之间的关系决定是否选中当前元素。很多人把它简称为“父选择器”,但它能表达的范围更大,包括后代、直接子代和相邻兄弟。article:has(> img) 选择拥有直接图片子项的文章;label:has(+ input:required) 可以根据紧随其后的必填输入给标签加标记;h2:has(+ p) 则匹配后面紧跟段落的二级标题。
.card:has(img) { grid-row: span 2; }
form:has(:invalid) button[type="submit"] { opacity: .55; }
h2:has(+ p) { margin-bottom: .4rem; }伪类还可以反映全屏、弹窗或媒体时间状态,例如 :fullscreen、:modal、:picture-in-picture、:playing、:paused、:current、:past、:future。它们依赖相应的 API、媒体类型和浏览器支持,语法存在不代表目标环境一定可用。使用前先查兼容性,并保留不依赖这些状态的基础样式。

伪元素不是 DOM 中新增的真实节点,但可以把它理解成一个可单独排版的局部盒子。::first-letter 选中块级文本的首字,常用来做首字下沉。::first-line 选中排版完成后的第一行,所以窗口宽度、字体或内容一变,它包含的字符也会变化。两者只允许使用一部分 CSS 属性,不能当成普通 span;某些前导标点还会和首字一起参与排版。
::placeholder 只负责输入框的占位提示。不要让占位文字代替永久标签,因为用户开始输入后,它就消失了。::file-selector-button 可以装饰 input[type="file"] 的内置按钮,同时保留原生文件选择能力。
article > p:first-of-type::first-letter {
float: inline-start;
font-size: 3.5em;
line-height: .8;
}
article > p:first-of-type::first-line { font-variant: small-caps; }
input::placeholder { color: #64748b; opacity: 1; }
input::file-selector-button::before 和 ::after 会在元素内容前后生成伪元素,设置 content 后才会出现。它们适合绘制装饰图形、引号或不承载核心信息的标记。关键文本不要只写在 content 里,因为复制、翻译和辅助技术不一定能稳定取得这些内容。图片和部分表单控件属于替换元素,也不能想当然地认为它们支持前后伪元素。
高亮伪元素包括 ::selection、::target-text、::spelling-error、::grammar-error,以及自定义高亮 ::highlight(name)。它们通常只接受与文字及其装饰有关的一组属性。::backdrop 表示全屏元素或模态对话框后方的视口层,不参与普通继承。::cue 用于 WebVTT 字幕,::cue(selector) 还能进一步限定字幕内部允许的标记。
Shadow DOM 会建立明确的样式边界。组件内部可以用 :host 选择宿主,用 :host(.compact) 根据宿主条件调整样式。:host-context() 按外部上下文匹配,但兼容性和标准状态需要谨慎核查。::slotted(selector) 只能在影子树样式中装饰分配到插槽的浅层节点,不能继续穿透这些节点的后代。这个限制正是为了避免组件内外的样式互相泄漏。
.tag::before { content: "#"; color: #7c3aed; }
::selection { color: white; background: #7c3aed; }
dialog::backdrop { background: rgb(15 23 42 / .72); }
::cue { color: white; background: rgb(0 0 0
写选择器时,我们可以依次确认四件事:最终要选谁,它位于什么结构中,现在是什么状态,样式要落在整个元素还是某个局部。先写清楚基础选择器,确实出现重复后,再用 :is()、:where() 或 :has() 整理。把全部逻辑塞进一条很长的表达式,通常只会让排错更困难。状态样式还要照顾键盘、指针和触控操作;不支持新选择器时,页面也应保留基本可用的反馈。
遇到选择器不生效,可以从 DOM 开始查。先在开发者工具里确认结构和属性,再用 element.matches('选择器') 验证目标元素能否匹配。匹配没问题,就继续检查伪类状态是否成立,以及规则是否被层叠和优先级覆盖。伪元素还要确认当前属性是否允许应用。如果整组规则完全没有出现,再看看其中是否混入了无效选择器。
:first-child、:has() 的方向读反。不用急着一次记住所有名字。先判断问题来自状态、结构、元素关系、局部内容还是组件边界,再选一个足够清楚的选择器。这样写出的规则更容易读,也更容易在出错时逐步验证。