列表样式做得是否稳妥,往往要到内容变长以后才看得出来。圆点换成方块只是最表面的一步;长文本怎么换行、嵌套层级是否清楚、图片加载失败怎么办,章节增删后编号能不能自动跟上,这些才是实际页面里更常见的问题。
我们可以把相关能力分成三层来理解:列表标记处理现成列表的外观,生成内容在元素前后补充视觉信息,计数器与计数样式维护编号的值和显示方式。下面会沿着这三层展开,具体看这些事情怎么做:
::marker 能做什么、不能做什么;::before、::after 与 content 生成装饰内容;counter-reset、counter-increment、counter-set 管理编号;counter()、counters() 输出单层与嵌套编号;@counter-style 定义固定、循环、符号、字母、位值和加和式计数系统;生成内容属于表现层。它适合图标、引号、编号、打印提示等“丢失后不影响理解”的信息,不适合承载表单标签、关键警告、价格、操作按钮或搜索引擎必须读取的正文。
浏览器排版列表项时,通常会生成正文所在的主体盒和一个标记盒。标记可以悬在正文外侧,也可以进入第一行参与行内排版。先分清这两个盒,后面遇到缩进不对、换行参差或标记被裁掉时,就知道该查哪里。
list-style-type:类型、字符串与隐藏标记list-style-type 只作用于会生成列表项盒的元素。最常见的是 li,任何设置了 display: list-item 的元素也可以使用它。这个属性会继承,初始值是 disc。不过,浏览器自带样式通常会把 ol 设为 decimal,也会给不同层级的 ul 安排不同形状,所以页面上实际看到的标记不一定是初始值。
常用值按用途记会更省事:
disc、circle、square、disclosure-open、disclosure-closed;decimal、decimal-leading-zero;lower-alpha、upper-alpha、lower-roman、upper-roman;cjk-decimal、simp-chinese-informal、japanese-formal 等;@counter-style 定义的名称;none。ol.steps {
list-style-type: decimal-leading-zero;
}
ul.commands {
list-style-type: "→ ";
}
.navigation li {
list-style-type: none;
}字符串标记会原样出现在每个列表项前,适合箭头、短词或单个符号。它不会自动编号,也不会根据当前序号改变内容,这一点和 @counter-style 不同。none 只是让标记不再绘制,列表的 DOM 语义仍然存在;即使某一项不显示标记,有序列表内部的计数顺序也不会中断。
嵌套列表的标记不符合预期时,可以先查浏览器是否为 ul ul、ul ul ul 写了更具体的默认规则。继承值可能被这些规则覆盖,这时需要给各层列表明确指定类型。
list-style-image:图片标记与可靠回退list-style-image 接受 url()、CSS <image> 或 none,并且同样会继承。这里最容易踩的坑是尺寸:浏览器会按图片的固有尺寸绘制标记,而这个属性没有可靠的宽高控制接口。一张 128×128 的图片放进来,很可能直接撑高整行,连列表布局也跟着变形。
.checklist {
list-style-image: url("check-16.png");
list-style-type: square; /* 图片失败时的后备标记 */
}
.checklist .details {
list-style-image: none; /* 阻止子列表继承图片 */
list-style-type: circle;
}实际使用时,我们先把素材裁到合适尺寸,再声明 list-style-type 作为加载失败时的回退,同时别忘了处理子列表继承。语法虽然允许渐变等 <image> 值,但不同浏览器对列表图片以及自定义计数符号中图片的支持并不完全一致,上线前仍要在目标环境中验证。
如果设计稿要求精确控制宽高、对齐、圆角和间距,就别继续勉强 list-style-image 了。隐藏原标记,再用 li::before 生成可布局的行内块会更好控制。不过,改成自绘标记以后,缩进和可访问性也要由你自己照顾。
list-style-position 与列表布局list-style-position 只有两个核心值:
outside:默认值。标记位于内容盒外,后续换行与正文起始边对齐;inside:标记成为第一行开头的行内内容,换行后的文字会回到内容盒起始边,看起来可能钻到标记下方。.article-list {
list-style-position: outside;
margin-inline-start: 0;
padding-inline-start: 2.25em;
}浏览器通常靠列表容器的 padding-inline-start 或 margin-inline-start 留出缩进。要清掉默认缩进,最好把两者都明确处理,不必猜当前浏览器究竟用了哪一个:
ul.reset {
margin-inline-start: 0;
padding-inline-start: 0;
}不过,在 outside 模式下把两者都归零,标记可能会悬到容器外面,遇到 overflow: hidden 还会直接被裁掉。通常要保留一段起始内边距,给标记留出位置。这里使用逻辑属性,也能兼顾从右到左和竖排书写模式。CSS 没有通用属性可以直接调节外置标记与正文之间的距离;如果间距必须和设计稿严格对齐,就要改用生成内容或明确的网格布局。
list-style 简写:顺序自由,但会重置省略项list-style 把 list-style-type、list-style-position 和 list-style-image 合并到一个声明里。三个部分的顺序通常可以交换,但没写出来的子属性会回到初始值。
/* type position image */
.tasks { list-style: square inside url("task.png"); }
/* 只设置类型,其余两项重置为 initial */
.plain { list-style: none; }简写里的一个 none 可以同时表示没有图片和没有标记类型,所以我们经常用 list-style: none 清除原生标记。简写适合在组件入口统一初始化。如果只是改其中一项,直接写对应的长写属性更稳妥,免得顺手清掉原有的图片或位置。

::marker:适合改文字外观,不适合完整盒模型::marker 选中列表项的标记伪元素。改颜色、字体、字号、字重、书写方向、空白处理或 content,都可以先考虑它,动画和过渡相关属性也能应用。不过,它的盒模型能力限制很多:margin、padding、width、height、background、border 和普通定位通常不会按我们熟悉的方式工作。
ol.release-notes li::marker {
color: #e34f26;
font-weight: 800;
font-variant-numeric: tabular-nums;
content: counter(list-item, decimal-leading-zero) " · ";
}现代浏览器提供了内建的 list-item 计数器,可以在 ::marker 的 content 中读取当前列表项序号。只改颜色或字号时,用 ::marker 很合适。要做 32px 的圆形徽章、阴影、固定宽度或复杂对齐,就换成 ::before 或额外的标记元素。
你可以拖动缩进,再切换标记位置,看看长文本的第二行从哪里开始。实验下方会同步显示当前 CSS,方便对照每次变化。
::before 和 ::after 会在元素内容的前后生成伪元素。它们默认位于元素盒内部,并按行内盒参与排版。父元素的颜色、字体等可继承属性会传给它们,边框、内边距这些非继承属性则不会自动复制。
下面这条打印规则会在链接前加一个标签。记得把空格也写进字符串,否则生成文字会直接贴住原来的链接文字:
@media print {
a[href]::before {
content: "(链接) ";
color: #666;
}
}伪元素也可以改成 block、inline-block、flex 或 grid 项,还能参与浮动和定位。比如把标题的 ::before 设为 display: block,生成内容会单独占一块,并把标题正文向后推。保持 inline 时,垂直外边距通常不会像块级盒那样改变行高。
h2::before {
content: "新章节";
display: block;
margin-block-end: .5em;
color: #64748b;
font-size: .75em;
}替换元素和部分表单控件对 ::before、::after 的支持并不一致。需要这类效果时,可以在 img、input 外包一层普通元素,再把伪元素挂到外层。关键功能不要依赖替换元素上的伪元素。

content 可以组合哪些内容content 的初始值是 normal。放在普通的 ::before、::after 上,它通常表示不生成内容;none 则明确关闭伪元素。需要显示内容时,可以把下面这些值组合起来:
"提示:",按字面显示,不解析其中的 HTML;url(icon.svg) 或其他支持的 <image>;attr(href)、attr(data-label);counter(chapter)、counters(section, ".");open-quote、close-quote、no-open-quote、no-close-quote;content: url(pdf.svg) / "PDF 文件"。a.pdf::after {
content: " [" attr(href) "]";
}
.status::before {
content: url("status.svg") / "状态:";
}如果字符串里写 <em>重要</em>,页面会把尖括号也原样显示出来,不会真的创建 HTML 元素。要插入换行,可以使用 CSS 转义 \A,并配合 white-space: pre 或 pre-line。如果只想把源码里的长字符串拆成多行,可以在行末用反斜杠转义源码换行;这种写法不会给最终内容增加换行。
.tip::before {
content: "第一行\A 第二行";
white-space: pre-line;
}资源加载失败,或者当前媒介不支持这种资源时,浏览器可能直接忽略它。因此,不能把资源型内容当成唯一提示。
attr():把元素属性显示到页面attr() 读取当前元素上的属性。最常见的用法,是在打印样式中把链接地址补到文字后面:
@media print {
a[href]::after {
content: "(" attr(href) ")";
overflow-wrap: anywhere;
}
}属性不存在时,attr() 得到的字符串通常为空,但它周围的固定文字仍会出现,于是页面可能只剩一对空括号。用属性选择器先限定匹配范围,就能避开这个问题。属性值里的标记或实体也不会再次解析,只会按文本处理。现代语法允许为 attr() 指定类型和回退,不过跨浏览器使用前要先确认目标环境的支持情况。
/* 只有具备 data-label 的元素才生成标签,避免空括号 */
[data-label]::before {
content: "[" attr(data-label) "] ";
}quotes 与四个引号控制值quotes 接受一对或多对“开引号—闭引号”字符串,而且会继承。第一对用于最外层,第二对用于下一层。如果实际嵌套比定义的层数更深,浏览器会继续使用最后一对。
:lang(zh-CN) {
quotes: "“" "”" "‘" "’";
}
q::before { content: open-quote; }
q::after { content: close-quote; }下面四个控制值不只决定符号是否显示,也会改变浏览器维护的引号深度:
open-quote:显示当前层开引号,并把深度加一;close-quote:把深度减一,并显示对应闭引号;no-open-quote:不显示符号,但把深度加一;no-close-quote:不显示符号,但把深度减一。利用这套深度机制,多段引文可以每段都显示开引号,只在最后一段补上闭引号:
blockquote { quotes: "“" "”"; }
blockquote p::before { content: open-quote; }
blockquote p::after { content: no-close-quote; }
blockquote p:last-child::after { content: close-quote; }这里的引号深度由 open-quote 和 close-quote 的出现顺序维护,并不简单等于 DOM 的嵌套层数。多语言页面还要结合 :lang(),分别设置符合各语言习惯的引号。
content 的不同来源切换几种模式,看看同一个链接的 ::after 如何使用固定字符串、attr() 和引号控制值生成不同内容。
你可以把计数器理解成一个有名字的整数,它会沿着元素树传递。我们通常在容器上创建或重置它,在重复出现的元素上递增,再到伪元素里读取并显示。计数器不只用于列表,标题、图注、代码行和问答卡片也能用同样的方法编号。

counter-reset、counter-increment 与 counter-set这三个属性都接受“计数器名称 + 可选整数”的序列,只是省略整数时采用的默认值不同:
counter-reset: chapter 把 chapter 重置为 0;counter-increment: chapter 默认增加 1;counter-set: chapter 把当前计数器直接设为 0;0、正数或负数。article {
counter-reset: section 0 figure 0;
}
h2 {
counter-increment: section;
counter-reset: subsection;
}
h3 {
counter-increment: subsection;
}
.appendix {
counter-set: section 20; /* 当前实例从 20 继续 */
}counter-reset 不只是改一个数值,它还会创建新的计数器实例,计数作用域也会随之改变。counter-set 更接近修改当前可见的实例;当前作用域没有这个名称时,它也会创建实例。子容器需要从头编号,就用 reset;序列中途要跳到指定值,通常用 set。
同一个元素既递增又输出时,页面显示的是递增后的值。所以,让编号从 1 开始的常见写法是:容器先重置为 0,条目加 1 后再读取。若第一次就要显示 0,可以把初值设为 -1。
counter()、counters() 与显示格式counter(name) 读取当前作用域里最近的同名实例,第二个参数可以指定计数样式。counters(name, separator) 则会收集从外层到内层的全部同名实例,再用给定分隔符连起来;第三个参数同样可以指定计数样式。
body { counter-reset: chapter; }
h1 { counter-increment: chapter; counter-reset: section; }
h2 { counter-increment: section; }
h1::before {
content: counter(chapter, upper-roman) ". ";
}
h2::before {
content: counter(chapter) "." counter(section
一个 content 里可以同时拼接多个计数器、字符串和属性。元素也不必是 list-item,普通块级元素照样能维护计数状态。
counters() 能得到 1.2.1每当嵌套容器执行一次同名的 counter-reset,就会创建一个新的同名实例。内层条目只递增离自己最近的实例,counters() 则从外到内读取整条实例链。
ol.outline {
list-style: none;
counter-reset: item;
}
ol.outline li {
counter-increment: item;
}
ol.outline li::before {
content: counters(item, ".") " ";
color: #2563eb;
font-weight: 700;
这段代码里,每个嵌套的 ol 都会匹配重置规则,于是各自创建 item 实例。最外层第二项下面的第一个子项就会输出 2.1。编号由 ::before 绘制,不再使用原生 marker,所以要清除 list-style,否则浏览器编号和自定义编号会同时出现。
不要把 counter-reset 放到负责显示编号的 ::before 上。这样会给每个伪元素创建新实例,页面上很容易出现一排重复的 0。重置通常放在容器或标题本体上,递增放在条目本体上,伪元素只负责输出。
设置了 display: none 的元素不会生成盒,它自身的计数器重置、递增和设值也不会执行。visibility: hidden 的元素仍参与布局,因此计数器操作照常发生,只是页面上看不到它。
.removed {
counter-increment: row;
display: none; /* 不递增 */
}
.reserved {
counter-increment: row;
visibility: hidden; /* 仍递增 */
}这会直接影响“隐藏的草稿章节是否占号”。不希望它占号,就让它离开渲染树;想保留位置和序号,只隐藏可见性即可。至于辅助技术如何处理,还要结合其他属性判断,不能只根据页面上是否看得见来推断。
改一改起始值、增量和分隔符,观察目录编号怎样变化。这个实验用 JavaScript 模拟数值,同时给出等价的 CSS,便于把看到的结果和声明对应起来。
@counter-style:定义自己的计数表示法计数器解决“现在数到几”,@counter-style 决定“这个数显示成什么样”。定义好的计数样式既能交给 list-style-type,也能作为 counter()、counters() 的格式参数。

一条计数样式规则由名称和一组描述符组成:
@counter-style triangles {
system: cyclic;
symbols: "▶" "▷";
suffix: " ";
}
ul.timeline { list-style-type: triangles; }常用描述符可以先看这张表:
符号可以是 Unicode 字符或字符串,语法上也允许图片引用。为了避免特殊字符被错误解析,通常给它们统一加上引号。图片符号的兼容性较弱,使用时要准备回退,并在目标浏览器中实测。
fixed 与 cyclic:有限序列和循环序列fixed 会按顺序逐个使用符号。符号用完以后,后续数值交给 fallback。它还允许在 system 后指定第一个符号对应的起始整数,这是其他系统没有的用法。
@counter-style release-stage {
system: fixed 0;
symbols: "草稿" "内测" "公测" "正式";
suffix: ":";
fallback: decimal;
}上例的四个符号分别对应 0、1、2、3。cyclic 也会按顺序取符号,不过用完后会回到第一个,适合交替装饰或周期图例。
@counter-style alternating {
system: cyclic;
symbols: "●" "○";
suffix: " ";
}如果 cyclic 只有一个符号,所有列表项都会使用同一个标记。默认的 suffix 通常类似“句点加空格”;自定义图形标记时,一般会把它改成一个空格或空字符串。
symbolic 与 alphabetic:重复增长和字母进位symbolic 每走完一轮,就把对应符号多重复一次。符号是 "*" "†" "§" 时,序列会变成 *、†、§、**、††、§§……用在短脚注上很直观,列表一长,标记也会很快变得过长。
alphabetic 的组合方式接近电子表格的列名。给定 A B C,序列就是 A、B、C、AA、AB、AC、BA……这个系统至少需要两个符号;只提供一个符号时,整条规则无效。
@counter-style footnote-signs {
system: symbolic;
symbols: "*" "†" "§";
suffix: " ";
range: 1 9;
fallback: decimal;
}
@counter-style short-alpha {
system: alphabetic;
symbols: "A" "B" "C";
suffix
numeric、negative 与 pad:自定义位值系统numeric 把 symbols 中的符号当作各个数位。十进制要按 0 到 9 的顺序列出符号,二进制只需要 0 和 1,十六进制则在十个数字后继续加上 A 到 F。
@counter-style binary {
system: numeric;
symbols: "0" "1";
prefix: "0b";
suffix: " ";
pad: 8 "0";
}negative 接受一个或两个符号,分别放在负数表示的前后,并位于普通 prefix、suffix 的内侧。pad 接受最小长度和填充符号。长度不够时才会补齐;超过最小长度不会被截断,其他编号也不会因此自动扩到相同宽度。
@counter-style accounting {
system: numeric;
symbols: "0" "1" "2" "3" "4" "5" "6" "7" "8" "9";
prefix: "¥";
negative: "(" ")";
pad: 4 "0";
suffix: " ";
}负号也计入表示长度,不过通常位于填充内容外侧。字母、位值、符号和加和系统都可以定义负数格式;如果某种系统本身无法表示负值,就需要依靠回退样式。
additive:按权重从大到小拼接additive 不使用 symbols,而是通过 additive-symbols 定义一组“非负权重 + 符号”。浏览器从高权重向低权重依次匹配,再把结果拼起来。权重必须严格按降序书写,否则整条规则无效。
@counter-style roman-compact {
system: additive;
additive-symbols:
1000 "M", 900 "CM", 500 "D", 400 "CD",
100 "C", 90 "XC", 50 "L", 40 "XL",
10 "X", 9 "IX", 5 "V", 4 "IV"
如果没有权重 1,一些看起来可以组合的数仍可能无法表示。例如只有 3 "Y", 2 "X" 时,4 不一定显示成 XX,而可能直接进入回退。实际定义时加入权重 1,可以避开大部分数值空洞。加和系统要表示负值,还要结合 negative 和可表示范围。
prefix、suffix、range 与 fallbackprefix 和 suffix 放在最终表示的前后。默认前缀为空,默认后缀通常类似 ". "。如果标记是图标,一般会把后缀改成一个空格。
range 可以定义一个或多个闭区间,区间之间用逗号分隔,也可以写 infinite。数值超出范围,或者当前算法无法表示时,会交给 fallback;它的默认值是 decimal。
@counter-style badge {
system: symbolic;
symbols: "●" "◆" "■";
prefix: "[";
suffix: "] ";
range: 1 9, 101 109;
fallback: decimal-leading-zero;
}范围可以防止符号越变越长。比如 symbolic 只定义三个符号,却拿来编号几百项,重复符号很快就会挤压正文。把适用范围限制在 1~9,后面的编号改用十进制,读起来会清楚得多。
extends:复用已有计数样式如果只想改现有样式的后缀、补位或范围,不用复制整套符号。system: extends <名称> 会继承目标样式的算法和符号,然后覆盖允许调整的描述符。
@counter-style two-digit-decimal {
system: extends decimal;
pad: 2 "0";
suffix: ") ";
}
@counter-style limited-two-digit {
system: extends two-digit-decimal;
range: 1 99;
fallback: decimal;
}扩展系统不能重新定义 symbols 或 additive-symbols,否则规则会失效。要创建十六进制样式,得先定义一个真正的 numeric 系统,再让其他样式扩展它。
speak-as 与可访问性边界页面上的视觉符号不一定能被语音技术正确解释。speak-as 可以请求辅助技术按项目符号、数字、单词、逐字拼读或另一种计数样式来朗读:
@counter-style dice {
system: additive;
additive-symbols: 6 "⚅", 5 "⚄", 4 "⚃", 3 "⚂", 2 "⚁", 1 "⚀";
speak-as: decimal;
suffix: " ";
}可选值包括 auto、bullets、numbers、words、spell-out 和计数样式名。auto 会根据系统推断朗读方式:字母系统倾向逐字拼读,循环系统倾向项目符号,其他系统倾向数字。扩展系统会沿用被扩展系统的策略。
但浏览器和辅助技术对 speak-as 的支持并不统一,只能把它当作额外增强,不能用它替代语义 HTML。有明确顺序的内容仍然应该使用 <ol>。装饰性符号还要避免造成重复朗读,并用目标屏幕阅读器验证。
列表、生成内容和计数样式组合使用时,很多问题都能从页面现象反推到具体职责。可以按下面这份清单逐项检查:
::before 生成了编号。两种方案选一种即可;决定自绘时,记得设置 list-style: none。outside 标记悬在内容盒外,祖先又设置了 overflow: hidden。给起始方向增加内边距,或者把标记改为内部布局。list-style-image 正按图片固有尺寸显示。可以先处理图片尺寸,也可以换成能设置宽高的伪元素。list-style 重置了图片和位置。局部修改时写长写属性会更安全。attr() 没读到属性值,固定的前后缀却仍在显示。先用属性选择器筛出确实拥有该属性的元素。counter-reset 规则。symbolic。用 range 限定适用区间,超出后交给 fallback。symbols。换成 additive-symbols,并按权重从大到小排列。content:样式失效后,信息也一起消失了。把关键内容放进 DOM,CSS 只保留可丢失的视觉增强。先看内容本身有没有顺序。有顺序就用 ol,只表达分组就用 ul。标记长什么样,可以留到 CSS 里再决定。
如果只改标记的颜色、字体或 content,::marker 已经够用。设计需要完整盒模型时,再换成 ::before 或真实元素。
编号需要自动维护时,把作用域理清楚:在哪里重置,哪些元素递增,又在哪里输出。遇到嵌套编号,可以优先检查 counters() 是否合适。
确定数值如何推进以后,再选择表示算法。同时为图片失败、范围溢出、辅助技术和特性不受支持的情况留好回退。
可以用一条简单的边界来检查最终实现:HTML 保存结构和关键信息,CSS 处理标记外观、可丢失的生成内容与自动编号。再为图片失败、范围溢出和特性不受支持的情况留好回退,页面就不会因为某个样式失效而变得难以理解。