写CSS写了这么多年,要说最常用却最容易被低估的选择器,我肯定投票给伪类。它不像类选择器那样直白,也不像伪元素那样能凭空变出装饰内容,但页面里那些细腻的交互反馈、结构化的列表样式、表单的即时校验提示,几乎全靠它在背后撑场子。经常有刚入门的朋友问我:“鼠标移上去变色怎么做?”“表格隔行变色有没有简单的办法?”“输入框内容不合法时怎么标红?”这些问题十有八九都能用伪类选择器解决,甚至一行CSS都不用写JS。
这篇指南我结合日常项目的真实用法,把常用伪类按类别拆开讲清楚,每个知识点都配了可直接抄的代码,最后还会聊一聊我在实际开发里踩过的坑。无论你是刚学会基础选择器的新手,还是想系统梳理伪类用法的老手,这份实战笔记应该都能帮上忙。文章有点长,建议收藏,遇到选择器相关的样式问题时翻出来对照着查。
1. 伪类选择器到底在选什么?——先建立整体认知
1.1 伪类不是伪元素,这两者可别搞混
很多初学者会把:hover和::before混为一谈,因为它们都带冒号,长得确实挺像。但这两者本质上解决的问题完全不同:伪类描述的是“元素在某种状态下的样子”,比如鼠标悬停、输入框聚焦、列表第n项;伪元素则是“在元素内部或外部凭空生成一个虚拟节点”,比如在元素前面插入一段内容、画一条装饰线。
我习惯这样记:伪类像是一个“条件判断器”,元素满足条件时样式生效;伪元素像是一个“影子节点”,它本身不在HTML里,却能通过CSS生成内容。写法上也有讲究——按规范,伪元素建议用双冒号::before、::after、::first-line、::first-letter,伪类用单冒号:hover、:focus。老一点的ie8只认单冒号的伪元素写法,不过现在基本不用管它了,直接按规范用双冒号就行。
还有一个区别值得注意:伪类可以叠加使用,比如a:hover:focus表示“鼠标悬停且获得焦点”的链接;而伪元素通常一个元素只能插两个虚子节点(::before和::after),并且它们不会被nth-child这类结构化伪类计算在内。这一点看起来不起眼,真到排查复杂选择器时会帮你省很多时间。
1.2 伪类的五大分类:状态、结构化、表单、逻辑与其他
伪类数量不少,但归纳起来大致是五大类,记住这个框架后,你看到陌生的伪类就不会慌,先把问题归类,再决定该查哪一类:
| 分类 | 常见伪类 | 典型使用场景 |
|---|---|---|
| 状态类 | :hover、:active、:focus、:focus-within、:focus-visible、:link、:visited | 鼠标交互、键盘焦点、链接访问状态 |
| 结构化 | :first-child、:last-child、:nth-child、:nth-of-type、:not、:empty、:only-child | 列表、表格、卡片布局的自动化样式 |
| 表单类 | :checked、:disabled、:required、:valid、:invalid、:placeholder-shown、:in-range | 表单校验反馈、自定义单复选框 |
| 逻辑类 | :is()、:where()、:not()、:has() | 选择器分组、父级判断、动态条件 |
| 其他 | :root、:target、:lang() | 全局变量、锚点跳转样式、多语言适配 |
别急着背下来。后面我会把每一类里真正高频的部分挑出来细讲,其他冷门的用到时再查就行。以前端项目为例,状态类和结构化类是出现频率最高的,表单类在后台管理系统的表单页面里能发挥巨大作用,逻辑类是近两年CSS升级后我最喜欢的新玩具,尤其是:has(),从“只能儿子找爹”变成了“爹也能找儿子”,写某些布局是真的爽。
2. 高频伪类逐个拆解:从写法到细节
2.1 状态类伪类的一线用法:hover、active、focus 别再凭感觉写
先看最常用的四个::hover、:active、:focus、:focus-within。很多开发者的认知停留在“hover是鼠标移入,focus是点击输入框”这个层面,但实际做项目时细节远比这多。
:hover是鼠标悬停状态,它不仅能用在链接上,任何元素都可以加。我给按钮、卡片、表格行、下拉菜单都写过hover样式。但要注意,移动端没有真正意义上的“鼠标悬停”,手指点击时触摸浏览器通常会先触发一次hover,如果点击后没有离开,这个hover状态会一直留在元素上,造成样式“卡住”的假象。我的处理方案是针对触摸设备写一层@media (hover: none),把依赖hover的交互视觉关闭,或者把这段逻辑交给JS的点击事件接管。
:active表示元素被按下的那一小段时间。它最常见的应用是给按钮加按压缩放反馈,比如:
.btn { transition: transform 0.1s ease; } .btn:active { transform: scale(0.96); }注意这个状态在键盘触发时不会出现,所以如果组件要考虑无障碍访问,不能只依赖:active做反馈。
:focus是元素获得焦点时的状态。关键误区在于:鼠标点击一个可聚焦元素时,现代浏览器往往也会让它获得焦点,并显示焦点环。对于追求视觉精致的产品,这个默认的焦点环常常被设计师吐槽,于是有些同学直接outline: none,这就把键盘用户的导航指示器给砍掉了,非常伤害无障碍体验。更好的做法是用:focus-visible,它只在键盘输入(Tab键移动焦点)时显示焦点环,鼠标点击时不会画额外边框,这样两拨人都照顾到了。
:focus-within是我特别推荐的“偷懒神器”,它的意思是“元素自身或任意后代获得焦点时,当前元素匹配”。你可以轻松实现一个整块搜索面板高亮的效果:
.search-panel:focus-within { border-color: #3b82f6; box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.2); }这样用户点进搜索面板里的输入框,整块区域都会亮起来,反馈感非常强,而且不需要给每个输入框单独写focus样式。
2.2 结构化选择器:nth-child 与 nth-of-type 的相爱相杀
结构化伪类的核心价值是减少对class的依赖,让样式随着DOM结构自动变化。最常见的需求就是表格隔行变色、列表每个第几项特殊处理。这里最容易被绕进去的就是:nth-child和:nth-of-type的区别。
一句话总结::nth-child是“在兄弟节点里数位置”,数的时候不管你是div还是span,只要是第几个就算第几个;:nth-of-type是“在同类型标签里数位置”,只有同标签的才参与计数。看个例子:
<ul> <h2>标题</h2> <li>项目1</li> <li>项目2</li> </ul>li:nth-child(2)能选中“项目1”,因为li是ul的第二个子元素。li:nth-child(1)选不中任何元素,因为ul的第一个子元素是h2。li:nth-of-type(1)能选中“项目1”,因为li元素同类里排第一。
这个坑在渲染后端接口返回的列表时很常见,比如列表里偶尔混入标题、分割线等非列表节点,用:nth-child就会出现隔行变色错乱的问题。所以我在处理混合结构的列表时,会优先考虑:nth-of-type或者给真正要计数的节点加个class。
再说:nth-child的参数写法,记住几个常用公式:
| 公式 | 选中的元素 |
|---|---|
odd或2n+1 | 序号为奇数的元素 |
even或2n | 序号为偶数的元素 |
3n+1 | 第1、4、7、10…个元素 |
-n+3 | 前3个元素 |
n+2 | 从第2个到末尾 |
我之前做卡片瀑布流布局时,需要给每行第一项去掉左边距,用的就是-n+3这种“前n个”写法。假设一行放3个卡片,每个卡片有左边距,那么第一行的第1、2、3个卡片不该有上边距,可以用:nth-child(-n+3)清掉。公式里的n从0开始计数,代入几个值就能推出规律,写的时候心里默算一遍,比我每次都查文档靠谱得多。
2.3 表单伪类:让校验反馈不再依赖JS
表单是伪类选择器最被低估的战场。以前做表单校验口味都要写一大段JS监听input事件,现在原生CSS就能做很多基础校验视觉反馈。
:required和:optional可以用来标记必填项和选填项。:valid和:invalid则是根据输入内容是否满足HTML校验规则自动生效,规则来源包括required、pattern、minlength、maxlength、type="email"等。比如:
input:required { border-left: 3px solid #f59e0b; } input:valid { border-color: #10b981; } input:invalid { border-color: #ef4444; }听起来很美好,但有一个体验陷阱:页面刚加载时,一个空的必填输入框在并没有“填过”的情况下也会被判定为无效,于是红框立刻怼到用户脸上,体验很差。解决办法是用:not(:placeholder-shown)或者:focus配合,让校验样式在“不是空状态”时才显示:
input:not(:placeholder-shown):invalid { border-color: #ef4444; }这样只有当用户确实输入了内容且内容不合法时,红色样式才出现。还有一个比较新的伪类:user-invalid,它在用户完成输入交互后才判定无效,语义更合理,不过兼容性还在追赶中,用之前记得查caniuse。
:placeholder-shown是另一个宝藏,它表示“当前占位符正在显示”,也就是输入框是空的时候。反过来:not(:placeholder-shown)就表示“输入框里有内容”。这个特性做浮动标签效果非常顺滑——占位符还在时,label和占位符重叠;一旦用户输入了内容,label就上移变小,同时改变颜色:
.form-item input:placeholder-shown + label { transform: translateY(0); font-size: 1rem; color: #9ca3af; } .form-item input:not(:placeholder-shown) + label, .form-item input:focus + label { transform: translateY(-20px); font-size: 0.75rem; color: #3b82f6; }不过要注意,现代浏览器实现浮动标签更推荐用:placeholder-shown配合:focus,因为部分浏览器不直接支持::placeholder的相邻选择器写法,总是踩到各种奇怪边界。
2.4 逻辑伪类:is、where、not、has 的现代用法
:is()和:where()都允许你把一串选择器合并成一个,写法上几乎一样,最大的区别是优先级。:is()的优先级是参数列表里优先级最高的那个,而:where()的优先级永远是0。这个区别在团队协作覆盖样式时特别关键。
举个例子。我有一段公共样式:
.sidebar :is(h1, .title, [data-tag]) { font-size: 16px; }:is()里含有类选择器.title,所以整条规则优先级是 (0, 1, 0) 加父级部分,后面想用一个简单类选择器覆盖它可能不够。如果我把:is换成:where:
.sidebar :where(h1, .title, [data-tag]) { font-size: 16px; }:where()优先级为0,这条规则就只受.sidebar那部分影响,后续任何类选择器都能轻松覆盖。所以我的习惯是:写组件默认样式用:where(),方便使用者覆盖;写业务里不想被覆盖的兜底样式用:is()或明确类名。
:not()的兼容性现在已经很好了,它可以顺手排除某些元素。像我经常给列表项加分割线时,会写成:
.list-item:not(:last-child) { border-bottom: 1px solid #e5e7eb; }这样就避免了最后一项多一条线的尴尬。注意老版本的:not()不能放复杂选择器,比如不能写:not(.a .b),新规范已经放开,但移动端某些低版本WebView可能不支持,保守起见还是用简单的类名、标签名和基本伪类更稳妥。
再来说说明星级父亲选择器:has(),它让你终于能“往父级选”了。比如我想把“包含图片的卡片”加上内边距,可以这样:
.card:has(> img) { padding: 0; }比如实现“导航里当前鼠标悬停在某个链接时,它的父级li高亮”,可以配合:hover一起写:
li:has(a:hover) { background: #f3f4f6; }这个能力在复杂组件中作用很大,但它最大的代价是性能。浏览器需要实时观察子元素状态来重新判定父级是否匹配,如果全页面滥用,重算成本会明显上升。我通常只在局部小范围元素上用,比如导航、卡片组。
3. 从速查到实战:伪类选择器组合出常见UI效果
3.1 导航菜单:用hover和focus-within做下划线动画
先从一个最常见的需求入手——导航菜单的鼠标移入事件,也就是热搜词里反复出现的“css 鼠标移入事件”。在纯CSS里,这就要靠:hover。我们希望鼠标移上去时,文字下方有一条下划线从左往右展开,而且键盘Tab聚焦到这个链接时也有同样的效果。
先看HTML结构,非常简单:
<nav class="nav"> <a href="#home">首页</a> <a href="#docs">文档</a> <a href="#about">关于</a> </nav>核心思路是给链接加一个相对定位,再用::after生成下划线,默认transform: scaleX(0)藏起来;当链接:hover或:focus-visible时,缩放到1,配合transform-origin控制展开方向:
.nav a { position: relative; padding: 8px 16px; text-decoration: none; color: #374151; } .nav a::after { content: ""; position: absolute; left: 0; bottom: 0; width: 100%; height: 2px; background: #3b82f6; transform: scaleX(0); transform-origin: left center; transition: transform 0.25s ease; } .nav a:hover::after, .nav a:focus-visible::after { transform: scaleX(1); }这里有一处容易翻车的地方:如果只给链接加:focus,鼠标点击后焦点环和下划线会一直留在元素上,视觉上很吵。用:focus-visible把鼠标点击的情况排除掉,键盘用户能获得清晰的焦点定位,鼠标用户也维持了干净的外观。
如果想强化“焦点落在导航容器内”的整块反馈,可以再给父容器写一个:focus-within,比如让导航栏整体背景变浅:
.nav:focus-within { background: #f9fafb; }这样的导航菜单兼顾了鼠标和键盘两种操作模式,在真实业务里已经算完成度很高的交互了。
3.2 表格与列表:结构化伪类的日常写法
后台管理系统里表格是最常见的信息载体,隔行变色、悬浮高亮、首行加粗这类需求几乎天天都有。用:nth-child和:hover配合,能写出非常干净且不用改HTML的表格样式。
先做斑马纹:
<table class="data-table"> <thead> <tr><th>姓名</th><th>部门</th><th>状态</th></tr> </thead> <tbody> <tr><td>张三</td><td>前端</td><td>在职</td></tr> <tr><td>李四</td><td>后端</td><td>在职</td></tr> <tr><td>王五</td><td>测试</td><td>休假</td></tr> </tbody> </table>.data-table tbody tr:nth-child(even) { background: #f9fafb; } .data-table tbody tr:hover { background: #eff6ff; }注意一个细节:斑马纹和悬浮高亮一起用时,如果悬浮色和偶数行背景色太接近,用户会分不清当前聚焦的是哪一行。我一般把悬浮色做得比斑马纹更深,或者加一条左边框作为额外标识:
.data-table tbody tr:hover { background: #eff6ff; box-shadow: inset 3px 0 0 #3b82f6; }列表的分割线也能用结构化伪类处理,上面提到过的:not(:last-child)是最常用的一招:
.list-item { padding: 12px 0; } .list-item:not(:last-child) { border-bottom: 1px solid #e5e7eb; }这样做的好处是,后端增删列表项时你不需要去改第一项或最后项的特殊类名,结构变了样式自动跟着变。如果列表里混入非列表节点,别忘了用:nth-of-type代替:nth-child,具体区别我在2.2节里已经说过。
3.3 表单校验与浮动标签:一张登录表单的完整样式
接下来把表单伪类串起来做一个完整的登录表单,顺带解决热搜词里“input居中”的问题。现代CSS做垂直水平居中用flex或者grid都非常简单,我通常用flex:
<div class="login-wrapper"> <form class="login-form"> <div class="form-item"> <input type="email" id="email" placeholder="请输入邮箱" required> <label for="email">邮箱</label> </div> <div class="form-item"> <input type="password" id="password" placeholder="请输入密码" minlength="6" required> <label for="password">密码</label> </div> <button class="login-btn" type="submit">登录</button> </form> </div>先让外层容器和表单居中:
.login-wrapper { display: flex; align-items: center; justify-content: center; min-height: 100vh; background: #f3f4f6; } .login-form { width: 100%; max-width: 360px; padding: 32px; background: #fff; border-radius: 12px; box-shadow: 0 10px 25px rgba(0, 0, 0, 0.05); }浮动标签的实现依赖输入框和label相邻,当输入框有内容时label浮起来:
.form-item { position: relative; margin-bottom: 24px; } .form-item input { width: 100%; padding: 16px 12px 8px; border: 1px solid #d1d5db; border-radius: 8px; font-size: 16px; outline: none; transition: border-color 0.2s, box-shadow 0.2s; } .form-item label { position: absolute; left: 12px; top: 12px; color: #9ca3af; pointer-events: none; transition: transform 0.2s, font-size 0.2s, color 0.2s; } .form-item input:focus { border-color: #3b82f6; box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.2); } .form-item input:placeholder-shown + label { transform: translateY(0); font-size: 16px; } .form-item input:not(:placeholder-shown) + label, .form-item input:focus + label { transform: translateY(-18px); font-size: 12px; color: #3b82f6; }这里的校验样式只会在用户输入内容后才触发,避免刚打开表单就看到一片红:
.form-item input:not(:placeholder-shown):valid { border-color: #10b981; } .form-item input:not(:placeholder-shown):invalid { border-color: #ef4444; }注意type="email"天生自带格式校验,邮箱填错时会自动匹配:invalid,不需要额外写正则。密码框设了minlength="6",长度不足同样会被判为无效,非常方便。
3.4 优惠券锯齿与涟漪光圈:伪元素配合伪类的小玩法
热搜词里有“css 优惠券圆切”和“css 涟漪光圈扩散”,这两个效果都和伪元素配合伪类有关,我放在一起说。比如一个卡片在鼠标移入时,中心区域向外扩散一圈光圈动画,交互触发点是:hover,光圈本体用::after生成:
.ripple-card { position: relative; overflow: hidden; width: 320px; height: 180px; background: linear-gradient(135deg, #6366f1, #8b5cf6); border-radius: 16px; } .ripple-card::after { content: ""; position: absolute; left: 50%; top: 50%; width: 0; height: 0; background: rgba(255, 255, 255, 0.3); border-radius: 50%; transform: translate(-50%, -50%); opacity: 0; transition: none; } .ripple-card:hover::after { width: 300px; height: 300px; opacity: 1; animation: ripple 0.6s ease-out forwards; } @keyframes ripple { from { width: 0; height: 0; opacity: 0.6; } to { width: 300px; height: 300px; opacity: 0; } }这个写法强调一个原则:动画过程中尽量不要让浏览器频繁重排和重绘,用width、height和opacity足够应付这类轻量动效。如果动画卡顿,可以优先考虑用transform: scale()代替宽高动画,性能会更稳。
至于“优惠券圆切”,通常是在优惠券左右两侧挖出半圆缺口,单靠一个元素比较难同时做两侧,常见方案是配合三个元素或者用遮罩和渐变。伪类在这里不是主角,但它是触发交互的重要时机。我一般会把这类圆形锯齿效果留给遮罩方案做,:hover负责控制缺口区域是否放大或变色,整体组件看起来就比较生动了。
4. 常见问题与排查技巧实录
4.1 优先级与覆盖问题:为什么写了不生效?
伪类选择器写了很多,结果发现样式没生效,这种情况我见得太多了。可能性很多,但排在最前面的永远是优先级。要记住,除:where()以外,绝大多数伪类的优先级等同于一个类选择器,也就是 (0, 1, 0)。比如a:hover的优先级比a高,但比a.link-class低,也比a#logo低得多。
写了一个button:hover去覆盖另一个带class的按钮样式时,如果对方写的是.primary-btn,那两者的优先级都是 (0, 1, 0),后出现的那一条获胜。这时候通常会有人急着在button:hover前面加!important,我的建议是别着急。先打开DevTools,找到这个元素,右下角会列出所有匹配的CSS规则,逐条看哪个优先级更高、哪个排序靠后,基本一眼就能定位。!important虽然能解决问题,但会把后续维护者逼疯。
还有一种小概率情况:选择器写漏了。比如input:hover写成了input: hover(多了一个空格),冒号后面不能有空白,写错就直接整条规则失效。我自己调试时会把选择器用引号包起来再检查一遍,或者先给元素随便加个背景色,确认选择器真的选得上,再继续调整具体属性。
4.2 兼容性速查:哪些伪类可以放心用,哪些要小心
伪类选择器的浏览器兼容性差异比较大,我这里整理一张常用伪类的速查表,按“可以放心用”和“需要谨慎用”来分:
| 伪类 | 兼容性 | 使用建议 |
|---|---|---|
:hover、:active、:focus | 所有浏览器 | 放心用 |
:link、:visited | 所有浏览器 | 放心用,但只有链接能匹配 |
:focus-within | 现代浏览器 | 可以放心用,项目若需兼容ie11需加预案 |
:focus-visible | 较新浏览器 | 建议优先用,但旧Edge/Safari老版本不支持 |
:nth-child、:nth-of-type | 现代浏览器 | 放心用 |
:not() | 现代浏览器 | 简单选择器放心用,复杂选择器注意平台 |
:checked、:disabled、:required | 现代浏览器 | 放心用 |
:valid、:invalid、:placeholder-shown | 较新浏览器 | 移动端大多支持,老WebView会有问题 |
:is()、:where() | 现代浏览器 | 放心用,性能也不错 |
:has() | 新浏览器 | 谨慎用,需降级方案 |
我的经验是:面向C端的项目,尽量别依赖:has()这种新特性,可以做渐进增强,等浏览器支持了再播放更丰富的样式,不支持也不影响核心功能。面向内部的工具系统,浏览器版本可控,:has()、:focus-visible这些新派伪类随便用,开发效率是真的高。
想确认具体版本支持情况,打开caniuse网站搜索伪类名称即可。不要把“现代浏览器支持”等同于“用户浏览器支持”,尤其是企业项目的用户环境千奇百怪,提前查一下能避免很多线上事故。
4.3 真实的坑:我在项目中踩过的伪类选择器问题
第一坑是:not()老语法的坑。我有一次给列表写:not(.active, .disabled),心想这是逻辑或的意思,结果在某个老版本浏览器里整条规则作废,列表样式直接崩了。后来改成了两段规则:
.list-item:not(.active):not(.disabled) { color: #374151; }这种写法在任何时代的CSS解析里都不容易出错,属于保守首选。
第二坑是触摸屏的:hover残影。移动端点击一个带hover样式的卡片后,卡片经常保持着淡蓝色遮罩不消失,直到点别处才恢复。我在一个营销活动页里遇到过,用户反馈按钮点完像“被水渍粘住”。解决方法是把基于hover的视觉反馈限制在真正支持悬停的设备上:
@media (hover: hover) { .card:hover { transform: translateY(-4px); box-shadow: 0 12px 24px rgba(0, 0, 0, 0.1); } }这样触摸设备不会加载这些悬停样式,残影直接消失。
第三坑是:nth-child嵌套索引计数问题。比如一个循环渲染的评论列表,外层有动态插入的广告位或加载提示,这时:nth-child的序号会乱掉。我踩过最狠的一次是“每3条评论插入一条广告”,结果广告也算进了计数,导致所有斑马纹错位。后来我把规则的匹配范围从“所有子元素”缩小到“同类型的评论项”,用:nth-of-type配合一个comment-item类才稳定下来。
第四坑是:has()动态内容的性能问题。给整个列表容器写.list:has(.list-item.active)这种选择器时,只要列表项增删,浏览器就要重新计算匹配关系,数据量稍微上来就会感受到明显的卡顿。我现在的原则是::has()只用来处理局部、静态、小范围的样式分支,涉及大量数据渲染的地方,老老实实在JS里加一个状态类名,既明确又高效。
最后分享一个我一直在用的调试技巧。DevTools的Elements面板里,选中一个元素后右键,菜单里有一个“Force State”的选项(中文版叫“强制状态”),可以直接强制元素处于:hover、:focus、:active这些状态。我写交互样式时从不用鼠标真的去悬停观察,都是靠这个开关快速切换,排查伪类没生效的情况特别顺手。养成这个习惯后,你调试伪类选择器的速度会快上一大截。