高级树计数:使用sibling-index()和sibling-count()的数学布局

TL;DR · AI 摘要
CSS新增sibling-index()和sibling-count()函数,让开发者无需JavaScript或复杂的:nth-child规则即可实现动态元素索引计算,一行代码解决级联动画延迟问题,支持任意数量元素。
核心要点
- sibling-index()返回元素在其父元素中的1基位置索引,sibling-count()返回父元素的子元素总数
- 一行CSS代码即可实现动态级联动画效果,无需预定义:nth-child规则或JavaScript操作
- 函数返回整数值可在calc()中直接运算,解决了以往counter()函数只能用于伪元素content的问题
结构提纲
按章节快速跳转。
sibling-index()和sibling-count()函数能够实现动态级联动画效果,无需:nth-child()规则或JavaScript工作区。
传统的Sass循环生成:nth-child()规则或JavaScript设置内联样式方法都存在维护困难和性能问题。
浏览器已知DOM树结构,现在CSS可以直接访问元素索引信息,无需重复定义硬编码规则。
sibling-index()返回1基位置索引,sibling-count()返回元素总数,两者都返回整数值可在calc()中运算。
通过减法运算可实现反向级联动画,函数组合使用可创建复杂的时间轴效果。
思维导图
用一张图看清主题之间的关系。
查看大纲文本(无障碍 / 无 JS 友好)
- CSS sibling-index()和sibling-count()函数
- 功能特性
- sibling-index()返回1基位置
- sibling-count()返回总数
- 返回整数值可参与运算
- 应用场景
- 动态级联动画
- 无需预定义规则
- 支持任意数量元素
- 技术优势
- 替代:nth-child()选择器
- 无需JavaScript操作
- 减少CSS规则数量
金句 / Highlights
值得收藏与分享的关键句。
Staggered cascade effect in one line of CSS without :nth-child() rules or JS workarounds. Works for 5 items or 5,000.
sibling-index() gives you the 1-based position of an element among its parent's children. First child returns 1. Fifth child returns 5.
Both functions resolve to <integer> — not <string>, an actual number. That means you can throw them into calc(), min(), max(), round(), mod().
You're telling the browser something it already knows. The browser built the DOM tree. It knows which element is the third child. It has the data.
认识一下 sibling-index() 和 sibling-count()。一行 CSS 代码实现交错级联效果,无需 :nth-child() 规则或 JS 变通方案。适用于 5 个项目或 5,000 个项目。 你知道那种情况吗?你有一个卡片网格,希望它们一个接一个地淡入?那种交错级联效果。看起来很棒。应该很简单。然而每次我构建它时,实现方式都让我觉得自己在做某种根本性的蠢事。
查看 Durgesh 创建的 [Dynamic Staggered Animations with CSS sibling-index() [forked]](https://codepen.io/smashingmag/pen/zxowBog)。
查看 Durgesh 创建的 [Dynamic Staggered Animations with CSS sibling-index() [forked]](https://codepen.io/smashingmag/pen/zxowBog)。
因为选项总是相同的。假设你想在包含 10 个项目的列表上设置交错动画延迟。你要么写一个 Sass 循环,输出十几个 :nth-child() 规则,每个规则都为特定位置硬编码一个 --index 变量:
/* 每个项目一条规则。希望列表永远不会增长。 */
li:nth-child(1) { --idx: 1; }
li:nth-child(2) { --idx: 2; }
li:nth-child(3) { --idx: 3; }
/* ... 还有八个这样的 ... */
li:nth-child(10) { --idx: 10; }
li {
animation-delay: calc(var(--idx) * 100ms);
}十个项目。十条规则。如果列表增长到 50?你限制它并希望最好,或者设置一个 Sass 循环,在构建时生成数百个选择器。像 Roman Komarov 这样的工程师想出了 O(√N) 策略——确实很聪明的东西——但你仍然需要 63 条规则来覆盖 1,023 个元素。
或者你在 JavaScript 中循环遍历元素并设置内联样式。style="--index: 3"。就在 DOM 中。工作正常。但也将在你的脚本中传播布局关注点,并在六个月后悄悄中断,当有人重构组件时没有意识到 CSS 依赖于 JavaScript 注入的变量。
这两种方法一直困扰我的原因相同:你在告诉浏览器一些它已经知道的事情。浏览器_构建了_DOM 树。它知道哪个元素是第三个子元素。它有数据。CSS 只是无法访问它。
好吧,现在可以了:
li {
animation-delay: calc(sibling-index() * 100ms);
}一行代码。适用于 5 个项目或 5,000 个。无需事件监听器。无需变异观察器。无需重新渲染。
sibling-index() 和 sibling-count() 是 CSS Values and Units Module Level 5 规范的一部分(如果你喜欢阅读 W3C 草案的话,是第 9 节)。该提案在经过大量讨论后通过 CSSWG issue #4559 获得批准。这些函数本身不需要参数——你只需使用它们。
- `sibling-index()` 给出元素在其父元素子元素中的基于 1 的位置。第一个子元素返回
1。第五个子元素返回5。它只计算元素节点——文本节点、注释和空白字符对它来说都是不可见的。 - `sibling-count()` 给出父元素拥有的元素子元素总数。基本上,CSS 等价于 JavaScript 中的
element.parentElement.children.length,但在你的样式表中可用。
两个函数都解析为 <integer>——不是 <string>,而是实际数字。这意味着你可以将它们放入 calc()、min()、max()、round()、mod()、三角函数如 `sin()` 和 `cos()`。当你写 calc(sibling-index() * 100ms) 时,CSS 处理类型转换并输出有效的 <time> 值。无需技巧。与 counter() 相比,后者返回字符串且只能存在于伪元素的 content 中——这是完全不同的东西。
一个容易让人困惑的澄清::nth-child() 是一个_选择器_。它选择元素。它不产生值。你不能写 calc(:nth-child() * 10px)——这不是有效的 CSS。sibling-index() 正好相反。它位于你的声明内部并给你一个可以计算的数字。它们解决不同的问题,直到现在我们一直在用胶带把 :nth-child() 粘贴到它从未设计的角色中。
值得借鉴的模式
一旦明白这些都是整数,想法就来了。
反向交错
想要最后一个项目首先动画?相减:
.card {
animation: fade-in 0.4s ease both;
animation-delay: calc((sibling-count() - sibling-index()) * 80ms);
}最后一个子元素得到 (N - N) * 80ms = 0ms——立即触发。第一个子元素得到 (N - 1) * 80ms。动画在页面加载的瞬间开始,而不是尴尬地暂停。
自动等宽
停止手动计数子元素以设置百分比:
.tab {
width: calc(100% / sibling-count());
}五个标签页?每个 20%。添加第六个?16.66%。删除两个?25%。无需媒体查询,无需调整大小观察器,无需 JavaScript。
也就是说,你可以想象一种场景,太多的项目会导致标签页非常窄,这时你可能想要使用其他方案,也许是 Flexbox 包装解决方案。
色调分布
在整个色轮上均匀分布颜色:
.swatch {
background-color: hsl(
calc((360deg / sibling-count()) * sibling-index()) 70% 50%
);
}三个项目获得相距 120° 的色调。十二个项目获得 30° 增量。调色板适应 DOM 中的任何内容,这通常是你需要 JavaScript 颜色库才能做的事情。
在圆圈中分布元素曾经意味着要在 JavaScript 中计算正弦和余弦值。现在 CSS 原生支持了 `sin()` 和 `cos()` 函数(Juan Diego Rodríguez 在 CSS-Tricks 上有一个很棒的实践指南介绍这些函数),结合树计数功能,整个实现变成了纯 CSS:
.radial-item {
--angle: calc((360deg / sibling-count()) * sibling-index());
--radius: 120px;
position: absolute;
left: calc(50% + var(--radius) * cos(var(--angle)));
top: calc(50% + var(--radius) * sin(var(--angle)));
transform: rotate(calc(var(--angle) * -1));
}六个项目?六边形。八个?八边形。添加或删除项目,布局会重新计算。无需 JavaScript 计算坐标。
Z-Index 层叠
构建卡片扇形?一行代码:
.card {
z-index: calc(sibling-count() - sibling-index());
}第一张卡片层叠最高,最后一张卡片得到 0。如果想要相反效果,可以翻转数学计算。
注意事项
这些需要注意的地方值得单独讨论,因为从规范中并不明显。
Shadow DOM 作用域
sibling-index() 和 sibling-count() 操作的是 DOM 树,而不是扁平化的视觉树。这个区别在使用 Web Components 时会让你感到困扰。
假设你有一个具有以下 shadow DOM 的自定义元素:
<section>
<slot></slot>
<div class="internal"></div>
</section>如果你用 sibling-index() 来样式化 .internal,它返回 2。总是如此。即使 <slot> 投射了 300 个元素也是如此。该函数看到 <section> 在 shadow 树中有两个子节点——<slot> 和 .internal div。对于计数来说,投射的 light DOM 内容不存在。
这里还有一个安全问题。如果 light DOM 样式表试图通过 ::part() 进入组件并使用 sibling-index(),浏览器会返回 0。完全为零。这是故意设置的防护墙,防止外部 CSS 探测第三方组件的内部结构。老实说,我认为这是正确的做法。
伪元素不计入
::before 和 ::after 不是兄弟元素。它们不会出现在 sibling-count() 中,也没有自己的 sibling-index()。但是——这是能节省你调试时间的部分——你可以在伪元素声明中使用这些函数。当你写 #target::before { width: calc(sibling-index() * 10px); } 时,它对 #target 评估 sibling-index(),而不是对伪元素评估。伪元素不是真实节点,所以函数会追溯到其起源元素。::slotted(*)::before 也是如此——它检查投影元素在 light DOM 中的索引。
`display: none` 仍然计入
这个让我很头疼。设置了 display: none 的元素会从布局树中消失。它们不占用空间。屏幕阅读器看不到它们。但它们仍在 DOM 中。
由于 sibling-index() 读取的是 DOM 树,而不是布局树,隐藏的元素会被计算在内:
<ul>
<!-- sibling-index() = 1 -->
<li>Apple</li>
<!-- sibling-index() = 2, invisible -->
<li style="display:none">Banana</li>
<!-- sibling-index() = 3, NOT 2 -->
<li>Cherry</li>
</ul>Cherry 是 3,不是 2。隐藏的香蕉仍然占据它的位置。
这对大多数布局来说无关紧要。但如果你正在构建类似搜索过滤器的功能,用 display: none 隐藏不匹配的项目,你的交错动画和圆形布局会出现间隙。可见项目保持它们原始的、非连续的索引。对于依赖连续计数的任何东西——径向菜单、比例宽度——你需要实际从 DOM 中移除过滤掉的节点,而不仅仅是隐藏它们。或者回退到 JavaScript 管理的索引。
注意:visibility: hidden 和 opacity: 0 也会被计算,但这感觉更直观,因为这些元素仍然占用空间。display: none 是那个狡猾的,因为元素在视觉上消失了但仍然占据一个 DOM 槽位。
#### 自定义属性立即求值
这很微妙。如果你尝试在父级上集中化索引:
.parent {
--idx: sibling-index();
}…那个 --idx 就在那里解析在 .parent 上。它获取父级自己的兄弟索引,将其锁定到该数字,每个子级继承该单个固定值。每个子级都得到相同的数字。几乎肯定不是你想要或期望的。
修复方法很简单——在需要它的元素上放置函数:
.child {
--idx: sibling-index();
animation-delay: calc(var(--idx) * 100ms);
}CSSWG 已经讨论过 @property 的 inherits: declaration 添加,理论上可以解决这个问题。如果你没有使用过 @property,它允许你定义自定义属性的类型、初始值和继承行为——比原始的 --variable 更多控制。但 inherits: declaration 的想法仍处于早期 CSSWG 讨论阶段,没有写入任何规范草案。可能需要数年才能实现——或者根本不会实现。即使今天有了 @property,也没有机制可以说"不要立即求值,等待子级"。所以目前,只需直接应用即可。
大规模性能
更改 DOM——即添加、删除、重新排序子级——会触发受影响兄弟元素的样式重新计算。浏览器在级联阶段(布局和绘制之前)处理此操作,因此比在 JavaScript 中循环并应用内联样式的旧方法更快。
但如果强制使用会有真实成本。在一个有 10,000 个子元素的容器开头插入一个元素,会迫使引擎重新计算其后所有 10,000 个元素的兄弟索引。对于常规内容——导航、卡片网格、标签栏——你永远不会注意到这个问题。但对于实时股票行情或有数千个节点不断滚动的无限滚动信息流,仍需在虚拟化窗口内使用 JavaScript 管理的索引。这些函数很快,但并非零成本。
浏览器支持
撰写本文时,Chrome/Edge 138 已在稳定版本中发布了这些函数(2025年6月),Safari 26.2 紧随其后。Firefox 还未在稳定版本中发布,但 Mozilla 的规范立场是积极的,实现工作正在积极进行中——在 Bugzilla 问题 #1953973 中跟踪。发布前请检查 caniuse 获取最新信息。
Chrome 和 Safari 合计覆盖全球约 75-80% 的流量。这是很强的多数,但 Firefox 的缺席意味着你仍需要回退方案。
对于当前发布,@supports 是你的好朋友:
/* 在所有地方都有效的工作基础方案 */
.item {
width: 25%;
animation-delay: 0ms;
}
/* 在支持的地方渐进增强 */
@supports (z-index: sibling-index()) {
.item {
width: calc(100% / sibling-count());
animation-delay: calc(sibling-index() * 80ms);
}
}Firefox 使用静态回退。其他浏览器使用数学布局。没有人会得到损坏的页面。
关于 polyfill:
循环遍历兄弟元素并设置内联样式的 JavaScript polyfill 正是这些函数存在的替代对象。但这并不意味着你只能使用硬编码的回退值。Juan Diego Rodríguez 写了一篇很棒的文章"如何等待 `sibling-count()` 和 `sibling-index()` 函数",阐述了在原生支持达到 Baseline 之前渐进增强的正确模型。他的方法使用现有 CSS 技术(如 Roman Komarov 的计数技巧)作为桥梁,而不是完整的 JavaScript polyfill。如果你需要在 Firefox 追上来的过程中发布生产就绪的内容,值得一读。
无障碍访问注意事项
需要说明这一点,因为容易兴奋而忘记:这些函数纯粹是视觉性的。它们改变事物的外观。它们不会改变事物的含义。
如果你使用 sibling-index() 数学来视觉上重新排序列表——通过 order 或网格定位——屏幕阅读器仍按源码顺序读取 DOM。键盘 Tab 键顺序也遵循 DOM。视觉布局和语义结构会相互矛盾,这是无障碍访问失败。
对于依赖树计数进行布局的数据网格、径向菜单或自定义列表框等交互组件,你仍需要 JavaScript 来同步 ARIA 属性。aria-posinset 和 aria-setsize 不知道 CSS 在计算什么。如果 CSS 说"这在视觉上是 7 个项目中的第 3 个"而 ARIA 说不同的内容(或没有),辅助技术用户会得到糟糕的体验。
在调试方面,Chrome DevTools 的最近版本允许你在 Elements 面板中直接检查计算出的 sibling-index() 和 sibling-count() 值,当数学运算不符合预期时很有帮助。

(大图预览)
即将到来的功能
当前规范只计算所有元素兄弟节点。但 CSSWG 已在 issue #9572 中记录了一个计划扩展:of <selector> 参数,匹配 :nth-child() 已支持的功能。
类似 sibling-index(of .active) 的东西可以让你只计算匹配特定选择器的兄弟节点。一个总体上是第八个子元素但 .active 子元素中是第三个的元素会返回 3。对于动态过滤或切换可见性的 UI,这可以在不需要 DOM 操作的情况下保持索引连续。
CSSWG 还讨论过 `children-count()` 和 `descendant-count()` 函数——前者会告诉你一个元素有多少个子元素(对父级驱动的布局有用),后者会递归计算所有后代元素。两者仍在提案阶段,但会完善树计数的故事:sibling-index() 和 sibling-count() 给你水平视图(我在同级中处于什么位置?),而 children-count() 和 descendant-count() 会给你垂直视图(我下面有什么?)。
_我在开头提到的那种感觉——为交错动画写十个 :nth-child() 规则并怀疑自己是否遗漏了明显的东西?你没有遗漏。那个明显的东西只是还没有出现而已。_
(gg, yk)