Smashing Magazine

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

8.7内容质量
高级树计数:使用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的问题

结构提纲

按章节快速跳转。

  1. sibling-index()和sibling-count()函数能够实现动态级联动画效果,无需:nth-child()规则或JavaScript工作区。

  2. 传统的Sass循环生成:nth-child()规则或JavaScript设置内联样式方法都存在维护困难和性能问题。

  3. 浏览器已知DOM树结构,现在CSS可以直接访问元素索引信息,无需重复定义硬编码规则。

  4. sibling-index()返回1基位置索引,sibling-count()返回元素总数,两者都返回整数值可在calc()中运算。

  5. 通过减法运算可实现反向级联动画,函数组合使用可创建复杂的时间轴效果。

思维导图

用一张图看清主题之间的关系。

查看大纲文本(无障碍 / 无 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.
    ⬇︎ 下载 PNG𝕏 分享到 X
  • sibling-index() gives you the 1-based position of an element among its parent's children. First child returns 1. Fifth child returns 5.
    ⬇︎ 下载 PNG𝕏 分享到 X
  • Both functions resolve to <integer> — not <string>, an actual number. That means you can throw them into calc(), min(), max(), round(), mod().
    ⬇︎ 下载 PNG𝕏 分享到 X
  • 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.
    ⬇︎ 下载 PNG𝕏 分享到 X
#CSS#Web开发#动画#布局
打开原文

认识一下 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 变量:

code
/* 每个项目一条规则。希望列表永远不会增长。 */
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 只是无法访问它。

好吧,现在可以了:

code
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() 粘贴到它从未设计的角色中。

值得借鉴的模式

一旦明白这些都是整数,想法就来了。

反向交错

想要最后一个项目首先动画?相减:

code
.card {
  animation: fade-in 0.4s ease both;
  animation-delay: calc((sibling-count() - sibling-index()) * 80ms);
}

最后一个子元素得到 (N - N) * 80ms = 0ms——立即触发。第一个子元素得到 (N - 1) * 80ms。动画在页面加载的瞬间开始,而不是尴尬地暂停。

自动等宽

停止手动计数子元素以设置百分比:

code
.tab {
  width: calc(100% / sibling-count());
}

五个标签页?每个 20%。添加第六个?16.66%。删除两个?25%。无需媒体查询,无需调整大小观察器,无需 JavaScript。

也就是说,你可以想象一种场景,太多的项目会导致标签页非常窄,这时你可能想要使用其他方案,也许是 Flexbox 包装解决方案。

色调分布

在整个色轮上均匀分布颜色:

code
.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:

code
.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 层叠

构建卡片扇形?一行代码:

code
.card {
  z-index: calc(sibling-count() - sibling-index());
}

第一张卡片层叠最高,最后一张卡片得到 0。如果想要相反效果,可以翻转数学计算。

注意事项

这些需要注意的地方值得单独讨论,因为从规范中并不明显。

Shadow DOM 作用域

sibling-index()sibling-count() 操作的是 DOM 树,而不是扁平化的视觉树。这个区别在使用 Web Components 时会让你感到困扰。

假设你有一个具有以下 shadow DOM 的自定义元素:

code
<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 树,而不是布局树,隐藏的元素会被计算在内:

code
<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: hiddenopacity: 0 也会被计算,但这感觉更直观,因为这些元素仍然占用空间。display: none 是那个狡猾的,因为元素在视觉上消失了但仍然占据一个 DOM 槽位。

#### 自定义属性立即求值

这很微妙。如果你尝试在父级上集中化索引:

code
.parent {
  --idx: sibling-index();
}

…那个 --idx 就在那里解析在 .parent 上。它获取父级自己的兄弟索引,将其锁定到该数字,每个子级继承该单个固定值。每个子级都得到相同的数字。几乎肯定不是你想要或期望的。

修复方法很简单——在需要它的元素上放置函数:

code
.child {
  --idx: sibling-index();
  animation-delay: calc(var(--idx) * 100ms);
}

CSSWG 已经讨论过 @propertyinherits: 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 是你的好朋友:

code
/* 在所有地方都有效的工作基础方案 */
.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-posinsetaria-setsize 不知道 CSS 在计算什么。如果 CSS 说"这在视觉上是 7 个项目中的第 3 个"而 ARIA 说不同的内容(或没有),辅助技术用户会得到糟糕的体验。

在调试方面,Chrome DevTools 的最近版本允许你在 Elements 面板中直接检查计算出的 sibling-index()sibling-count() 值,当数学运算不符合预期时很有帮助。

图片 1: sibling-index devtools
图片 1: sibling-index devtools

(大图预览)

即将到来的功能

当前规范只计算所有元素兄弟节点。但 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() 规则并怀疑自己是否遗漏了明显的东西?你没有遗漏。那个明显的东西只是还没有出现而已。_

图片 2: Smashing Editorial(gg, yk)