写列表样式这块我踩过的坑比很多同事都多。记得有次改一个后台管理系统需求方要求侧边栏每个菜单前面的小圆点跟着主题色走鼠标悬停时还要单独变色。我打开源码一看好家伙全部用的background-image塞小图标改一个颜色要导出一套图。从那以后我就养成了习惯凡是ul li前缀点这种需求能不用背景图就不用背景图用纯CSS的几行声明就能搞定而且颜色、形状、变色全部代码可控。这篇文章就专门聊聊“ul li前缀点加颜色和变色”这件事。核心关键词很明确CSS、ul、li、前缀点、变色。我会从原理讲到实战把 ::marker、list-style-image、::before伪元素这三种方案全部拆开给你一套可以直接抄的写法再附带几个真实项目里经常遇到的坑。 ## 1. 这个需求到底在解决什么问题 ### 1.1 先认清“ul li前缀点”的三个可玩维度 很多人一上来就写list-style: none然后开始用背景图其实是没搞清楚列表标记的本质。浏览器渲染一个li元素时并不是只渲染一个盒子而是渲染了三个部分最外层的列表项盒子principal box、内容盒子装文字的部分以及一个独立的标记盒子marker box。那个“前缀点”就是标记盒子它天生独立存在你完全可以通过CSS单独控制它的颜色、大小、位置。 正因为标记盒子是独立渲染的我们能玩的维度有三个 1. **颜色**标记盒的color可以单独设置并且可以在hover、focus、checked等状态下单独变化。 2. **形状**默认是实心圆点但可以通过content把它换成方块、空心圈、三角、对勾甚至emoji。 3. **位置**list-style-position: outside时标记在内容外面inside时标记和内容在同一行内。 理解了这三个维度你就会发现以前那些“一个点都要切图”的需求其实都是几行CSS的事。 ### 1.2 为什么“不用背景图”反而是更好的约束 标题里强调“不使用背景图”这个约束在实际项目中特别有价值。用背景图实现列表前缀点通常的做法是 css li { list-style: none; background: url(dot.png) left center no-repeat; padding-left: 20px; }这写法短期看没问题但它有四个硬伤换颜色要重新切图主题换色时工作量翻倍。背景图多了HTTP请求变多如果做成雪碧图坐标调起来又非常痛苦。高清屏要准备2x图否则点会发虚。hover变色时要再准备一张不同颜色的图或者用背景叠加技巧代码瞬间膨胀。当你不允许用背景图时思路反而会打开你会去翻CSS原生能力会发现::marker、::before这些东西代码少、效果好、可维护性高。所以这不只是一个技术选型问题更是一个思维约束——逼你走那条更优雅的路。2. 常见方案对比::marker、list-style-image、::before2.1 ::marker最合语义的新生力量::marker这个伪元素是专门用来控制列表标记盒子的。它的兼容性在今天已经很不错了Chrome 86、Firefox 68、Safari 11.1都支持除非你要兼容很老的企业浏览器环境否则基本可以放心用。它的写法非常直观ul { list-style-position: outside; } li::marker { color: #f97316; font-size: 1.2em; } li:hover::marker { color: #ef4444; }这里有个细节很多人第一次没转过弯来li { color: #333 }改的是文字颜色而li::marker { color: #f97316 }改的是前缀点颜色两者互不干扰。因为它们本来就是两个独立盒子伪元素选择器正好精准命中标记盒子。::marker的优点是语义最正、性能最好不用额外加任何HTML结构缺点是它支持的CSS属性有限目前主要就是color、font-size、font-family、font-weight、content、white-space这些想做边框、背景、圆角这些复杂形状是做不到的。2.2 list-style-image本质还是在用图形list-style-image允许你给列表标记设置一张图片li { list-style-image: url(arrow.png); }很多教程会把这条命令写进去但我个人非常不建议在“前缀点变色”这个需求里用它。原因很直接list-style-image渲染的是一张位图它的颜色是由图片本身决定的CSS的color完全管不到它。也就是说你没办法只靠CSS让这张图在hover时变色真要做只能换图。有人会说那我用SVG的data URI把不同颜色的SVG嵌进去不就行了确实能行但data URI本质上还是图像可维护性和代码可读性都比较差而且一旦需要外部控制颜色根本绕不过去。所以这个方案当作备选知识了解即可真正实战我推荐的是下一个方案。2.3 ::before自由度和兼容性的最优解如果要把前缀点做成方块、空心圈、三角、对勾或者让前缀点背景有渐变、边框、圆角::before伪元素几乎是唯一的选择。核心写法是这样的.dot-list { list-style: none; padding-left: 0; } .dot-list li { position: relative; padding-left: 22px; } .dot-list li::before { content: ; position: absolute; left: 0; top: 0.4em; width: 10px; height: 10px; background-color: #3b82f6; border-radius: 2px; }li::before本质上是在每个列表项内容盒子的左上角绝对定位一个小方块。因为li设了position: relative所以伪元素的定位基准是每个li自己不会跑偏。这个方案最大的优势是背景色、边框色、圆角、宽度、高度全部可以直接写CSS颜色由background-color或border-color控制hover变色只要覆盖这两个属性就行。而且它兼容IE8以后的所有浏览器老项目里也能用。三种方案的定位我总结成一张表方便你选型需求场景推荐方案理由只改圆点颜色追求最简代码::marker语义最正原生盒子改动最小兼容老浏览器只有简单圆点::before border-radius兼容IE8代码量也不大方块、三角、空心圈等自定义形状::beforecontent自由任何形状都能画hover、focus、checked状态变色::before或::marker两个都可以做状态变色但::before更稳复杂背景、渐变、阴影的前缀点::before只有它支持背景和边框绘制严格禁用图像的场景::marker / ::before两个都不用背景图纯CSS绘制3. 核心实现让前缀点换颜色和变色的三种写法3.1 基础版单独给::marker变色先说最简单的写法。如果你只需要默认带圆点、方块样式的列表然后给前缀点设置一个固定颜色hover时再变个色::marker就够了。ul classmarker-list li产品需求文档评审/li li接口联调与回归测试/li li发布上线与数据监控/li /ul.marker-list { list-style-position: outside; /* 让标记盒子和文字的间距通过padding控制 */ padding-left: 1.2em; } .marker-list li { color: #334155; margin-bottom: 12px; line-height: 1.8; } .marker-list li::marker { content: ●; color: #6366f1; font-size: 0.9em; /* 用em是因为它会跟随父级字号比例稳定 */ } .marker-list li:hover { color: #1e293b; } .marker-list li:hover::marker { color: #f43f5e; }这段代码里有三个容易踩的点content: ●如果没写::marker会使用list-style-type的默认值disc。写了以后形状完全由你控制。font-size用em而不是px因为列表项字号如果从16px变成14pxem会自动缩放圆点不会显得突兀。::marker的color和li的color是独立的所以hover时你既可以只变点也可以同时变文字和点全看你的选择器怎么组合。3.2 形状扩展方块、圆点、空心圈、三角、对勾::marker的content能写文本但画不出有边框、有背景的形状。所以下面这些形状我都用::before来画你直接照抄就能用。实心圆点版.circle-list li::before { content: ; position: absolute; left: 0; top: 0.45em; width: 8px; height: 8px; background-color: #3b82f6; border-radius: 50%; }圆角方块版也叫胶囊方块.rounded-square-list li::before { content: ; position: absolute; left: 0; top: 0.42em; width: 9px; height: 9px; background-color: #10b981; border-radius: 2px; }标准正方形版.square-list li::before { content: ; position: absolute; left: 0; top: 0.42em; width: 10px; height: 10px; background-color: #f59e0b; border-radius: 0; }空心圆版.round-outline-list li::before { content: ; position: absolute; left: 0; top: 0.4em; width: 10px; height: 10px; border: 2px solid #8b5cf6; border-radius: 50%; background-color: transparent; }三角版用border技巧画.triangle-list li::before { content: ; position: absolute; left: 2px; top: 0.42em; width: 0; height: 0; border-left: 6px solid #ef4444; border-top: 4px solid transparent; border-bottom: 4px solid transparent; }每个形状里的top值我都用了em这是经验之谈。因为em会跟随字号变化当列表项line-height为1.8时单行文字中心大约在0.9em处而元素高度如果是10px折合0.625em所以top: 0.42em左右会让小方块正好贴住第一行文字中心。如果你的行高变了把这个值微调一下就行。3.3 动态变色hover、focus、选中态怎么接前缀点“变色”是这个需求的核心。变色不是简单写个:hover就完事你要明确在什么状态下变。鼠标悬停态.dot-list li:hover::before { background-color: #f43f5e; }键盘焦点态如果li里放了a或者button用:focus-within可以在内部元素聚焦时让前缀点变色.dot-list li:focus-within::before { background-color: #f59e0b; box-shadow: 0 0 0 3px rgba(245, 158, 11, 0.25); }选中态radio hack下拉选项这种场景可以用input:checked配合兄弟选择器来控制input:checked .dot-list li::before { background-color: #10b981; }:focus-within这个选择器在导航菜单、自定义下拉框里特别实用。因为用户可以用Tab键把焦点移进列表项视觉上你需要明确告诉他“现在焦点在这个项上”前缀点变色就是一个很好的反馈。3.4 用CSS变量一套参数全站变色做主题换色时前缀点颜色不应该写死。用CSS变量包一层以后改主题只需要改一个位置。:root { --marker-color: #6366f1; --marker-hover-color: #f43f5e; --marker-size: 10px; --marker-shape-radius: 50%; } .dot-list li::before { content: ; position: absolute; left: 0; top: 0.4em; width: var(--marker-size); height: var(--marker-size); background-color: var(--marker-color); border-radius: var(--marker-shape-radius); } .dot-list li:hover::before { background-color: var(--marker-hover-color); }这里有个细节CSS变量定义在:root上所有列表项都能继承到但如果你在某一个li上重新定义了--marker-color那这个li的伪元素会优先使用局部变量可以实现“某个特殊项颜色不一样”的效果。.dot-list li.is-warning { --marker-color: #f59e0b; --marker-hover-color: #ef4444; }不需要写任何额外规则CSS变量会自动覆盖颜色。这套玩法在组件化开发中特别方便。4. 实战做一个无背景图的自定义列表菜单理论说完了我直接给一个能跑的项目片段。这是一个后台常见侧边栏菜单菜单项前面的前缀点是圆角方块默认主题色hover时整个菜单背景变化、前缀点单独变色激活菜单项的前缀点颜色和普通项不同。4.1 场景需求确认这个菜单要满足三个条件前缀点不能使用background-image保持纯CSS绘制。不同菜单项可以有不同前缀点颜色。当前激活菜单项通常通过.active类控制前缀点要醒目。4.2 完整HTML结构nav classside-menu ul li classmenu-item a href#项目总览/a /li li classmenu-item is-active a href#代码仓库/a /li li classmenu-item a href#构建部署/a /li li classmenu-item a href#监控告警/a /li /ul /nav4.3 完整CSS代码.side-menu { width: 220px; background-color: #1e293b; border-radius: 8px; padding: 16px 0; } .side-menu ul { list-style: none; margin: 0; padding: 0; } .side-menu .menu-item { position: relative; padding: 10px 16px 10px 30px; transition: background-color 0.2s ease; } .side-menu .menu-item a { color: #cbd5e1; text-decoration: none; font-size: 14px; display: block; } /* 默认小方块青色 */ .side-menu .menu-item::before { content: ; position: absolute; left: 14px; top: 50%; transform: translateY(-50%); width: 8px; height: 8px; background-color: #22d3ee; border-radius: 2px; transition: background-color 0.2s ease, transform 0.2s ease; } /* hover方块变圆颜色变橙 */ .side-menu .menu-item:hover { background-color: #334155; } .side-menu .menu-item:hover::before { background-color: #fb923c; border-radius: 50%; } /* 选中态紫色并放大一圈 */ .side-menu .menu-item.is-active { background-color: #312e81; } .side-menu .menu-item.is-active a { color: #fff; } .side-menu .menu-item.is-active::before { background-color: #a78bfa; width: 10px; height: 10px; border-radius: 50%; }4.4 这版代码里的关键思路我特意在::before里用了top: 50% transform: translateY(-50%)而不是top: 0.42em。原因在于菜单项高度由padding line-height决定如果写死top: 0.42em当菜单项高度变化时方块会变得偏离垂直中心。用top: 50%加transform的方式不管高度多少小方块永远垂直居中。这是两种定位思路的取舍纯文本列表用em定位更方便有内边距的交互型菜单用居中定位更稳。两种写法都要会。另外transition同时写在background-color和border-radius上可以让颜色变化和圆角变化都有过渡动画。注意::before本身也要写transition否则hover时只变形状不变色看起来会很干。这段代码你直接复制到空HTML文件里就能看效果不需要任何背景图。5. 进阶玩法与自定义下拉框、导航联动很多热词里提到一个场景用divulli组合做自定义下拉框替代原生select。这种自定义下拉框的列表项前缀点变色其实是很有意思的联动题。5.1 用radio hack实现选中态前缀点变色先看一个极简版。我用两个radio控制两个面板的显隐再配合:checked让当前选中项的圆点变绿。纯CSS就能做到“点选哪项哪项加点标记变绿”。HTML结构div classcustom-select input typeradio namecity idcity-gz checked input typeradio namecity idcity-sh label forcity-gz classvalue-display广州/label label forcity-sh classvalue-display上海/label /div ul classoption-list li label forcity-gz广州/label /li li label forcity-sh上海/label /li /ulCSS关键部分.option-list { list-style: none; padding: 0; margin: 8px 0 0; border: 1px solid #e2e8f0; border-radius: 8px; overflow: hidden; } .option-list li { position: relative; padding: 10px 16px 10px 30px; } .option-list li::before { content: ; position: absolute; left: 10px; top: 50%; transform: translateY(-50%); width: 8px; height: 8px; border-radius: 50%; background-color: #cbd5e1; transition: background-color 0.2s ease; } /* 选中哪个radio对应li的前缀点变绿 */ #city-gz:checked ~ .option-list li:first-child::before, #city-sh:checked ~ .option-list li:last-child::before { background-color: #10b981; } /* 同时让对应的选项文字变粗 */ #city-gz:checked ~ .option-list li:first-child, #city-sh:checked ~ .option-list li:last-child { font-weight: 600; color: #0f172a; }原理是~通用兄弟选择器可以让radio状态影响到后面的兄弟元素。这里radio和ul是兄弟关系所以能用:checked控制列表和列表项。这种方法不需要写一行JavaScript非常契合“纯CSS”这个主题。5.2 键盘导航焦点态的前缀点变色自定义下拉框还有一个常见的痛点键盘Tab聚焦时当前项没有视觉反馈。用:focus-within可以补上注意是从li内部的label或a聚焦时触发。.option-list li:focus-within::before { background-color: #f59e0b; box-shadow: 0 0 0 3px rgba(245, 158, 11, 0.3); }这个做法的好处是鼠标hover和键盘聚焦的反馈样式可以分开键盘用户也能清晰感知自己在哪个选项上。无障碍细节做不做往往是区分专业前端和“能干活”前端的一条线。5.3 利用data属性让前缀点的形状和颜色由HTML控制如果菜单项很多CSS里为每个项写一遍:nth-child太啰嗦。可以借助>ul classconfig-list li>.config-list li[data-dotblue]::before { background-color: #3b82f6; } .config-list li[data-dotgreen]::before { background-color: #10b981; } .config-list li[data-dotorange]::before { background-color: #f59e0b; }这种写法的好处是颜色配置跟HTML的内容走业务同学不碰CSS也能改色。我在好几个内部系统中都用了这个模式维护成本非常低。甚至可以配合CSS变量让>.config-list li[data-dotblue] { --dot-color: #3b82f6; } .config-list li::before { background-color: var(--dot-color, #94a3b8); }这样即使某个项忘了加>/* 错误示范 */ li::before { background-color: #3b82f6; } li:hover::before { background-color: #ef4444; transition: background-color 0.2s; }/* 正确示范 */ li::before { background-color: #3b82f6; transition: background-color 0.2s ease; } li:hover::before { background-color: #ef4444; }前者只有鼠标移出时才有过渡鼠标移入时没有观感很别扭。后者在移入移出两个方向都有过渡效果。6.6 真机上圆点发糊现象同样的代码在普通屏上很清晰在自己手机上圆点边缘发虚。原因小尺寸色块在非整数倍缩放时会触发抗锯齿如果宽度、高度是奇数像素更容易发虚。对策优先使用偶数的宽高比如8px、10px、12px。圆点尽量用border-radius: 50%不要用border-radius: 999px两者在特殊尺寸下的圆润程度有细微差别。如果整体用transform: scale()做放大效果注意缩放后的像素不是整数可以配合will-change: transform减少毛边。最后分享一点我做列表样式的心得这里没有标准答案适合自己的项目状态就是最好的。我自己在项目里的默认组合是普通静态列表用::marker因为代码最少需要各种形状和交互状态的菜单、下拉框、选项列表统一用::before方案因为可控性最强涉及主题换色时一定把颜色抽成CSS变量不然后面每个页面都要返工改色。还有一个小技巧你可能会用得上如果想让前缀点的颜色和某段文字的颜色保持一致可以这样写li::before { color: inherit; }但要注意::before的背景色不会继承所以对background-color实现的方块来说这个写法无效。我用得更多的做法是把颜色放在li的CSS变量里li { --dot-bg: currentColor; } li::before { background-color: var(--dot-bg); }这样改li的文字颜色时前缀点会自动跟随完全不需要额外写一条规则。遇到那种“导航菜单文字变色前缀点也跟着变”的需求这个写法非常省心。最后再补一句所有选择器方案里优先级问题是最容易翻车的。li::before的优先级确实低于li.is-active::before但如果你在li本身写了background-color它和::before的background-color并不冲突一个是li的盒子一个是伪元素的盒子。搞清楚“谁在哪个盒子上画颜色”所有列表样式问题都能迎刃而解。