> All in One 586: How to Wait for the sibling-count() and sibling-index() Functions

Ads

Monday, January 13, 2025

How to Wait for the sibling-count() and sibling-index() Functions

New features don’t just pop up in CSS (but I wish they did). Rather, they go through an extensive process of discussions and considerations, defining, writing, prototyping, testing, shipping handling support, and many more verbs that I can’t even begin to imagine. That process is long, and despite how much I want to get my hands on a new feature, as an everyday developer, I can only wait.

I can, however, control how I wait: do I avoid all possible interfaces or demos that are possible with that one feature? Or do I push the boundaries of CSS and try to do them anyway?

As ambitious and curious developers, many of us choose the latter option. CSS would grow stagnant without that mentality. That’s why, today, I want to look at two upcoming functions: sibling-count() and sibling-index(). We’re waiting for them — and have been for several years — so I’m letting my natural curiosity get the best of me so I can get a feel for what to be excited about. Join me!

The tree-counting functions

At some point, you’ve probably wanted to know the position of an element amongst its siblings or how many children an element has to calculate something in CSS, maybe for some staggering animation in which each element has a longer delay, or perhaps for changing an element’s background-color depending on its number of siblings. This has been a long-awaited deal on my CSS wishlists. Take this CSSWG GitHub Issue from 2017:

Feature request. It would be nice to be able to use the counter() function inside of calc() function. That would enable new possibilities on layouts.

However, counters work using strings, rendering them useless inside a calc() function that deals with numbers. We need a set of similar functions that return as integers the index of an element and the count of siblings. This doesn’t seem too much to ask. We can currently query an element by its tree position using the :nth-child() pseudo-selector (and its variants), not to mention query an element based on how many items it has using the :has() pseudo-selector.

Luckily, this year the CSSWG approved implementing the sibling-count() and sibling-index() functions! And we already have something in the spec written down:

The sibling-count() functional notation represents, as an <integer>, the total number of child elements in the parent of the element on which the notation is used.

The sibling-index() functional notation represents, as an <integer>, the index of the element on which the notation is used among the children of its parent. Like :nth-child(), sibling-index() is 1-indexed.

How much time do we have to wait to use them? Earlier this year Adam Argyle said that “a Chromium engineer mentioned wanting to do it, but we don’t have a flag to try it out with yet. I’ll share when we do!” So, while I am hopeful to get more news in 2025, we probably won’t see them shipped soon. In the meantime, let’s get to what we can do right now!

Rubbing two sticks together

The closest we can get to tree counting functions in terms of syntax and usage is with custom properties. However, the biggest problem is populating them with the correct index and count. The simplest and longest method is hardcoding each using only CSS: we can use the nth-child() selector to give each element its corresponding index:

li:nth-child(1) {
  --sibling-index: 1;
}

li:nth-child(2) {
  --sibling-index: 2;
}

li:nth-child(3) {
  --sibling-index: 3;
}

/* and so on... */

Setting the sibling-count() equivalent has a bit more nuance since we will need to use quantity queries with the :has() selector. A quantity query has the following syntax:

.container:has(> :last-child:nth-child(m)) { }

…where m is the number of elements we want to target. It works by checking if the last element of a container is also the nth element we are targeting; thus it has only that number of elements. You can create your custom quantity queries using this tool by Temani Afif. In this case, our quantity queries would look like the following:

ol:has(> :nth-child(1)) {
  --sibling-count: 1;
}

ol:has(> :last-child:nth-child(2)) {
  --sibling-count: 2;
}

ol:has(> :last-child:nth-child(3)) {
  --sibling-count: 3;
}

/* and so on... */

This example is intentionally light on the number of elements for brevity, but as the list grows it will become unmanageable. Maybe we could use a preprocessor like Sass to write them for us, but we want to focus on a vanilla CSS solution here. For example, the following demo can support up to 12 elements, and you can already see how ugly it gets in the code.

That’s 24 rules to know the index and count of 12 elements for those of you keeping score. It surely feels like we could get that number down to something more manageable, but if we hardcode each index we are bound increase the amount of code we write. The best we can do is rewrite our CSS so we can nest the --sibling-index and --sibling-count properties together. Instead of writing each property by itself:

li:nth-child(2) {
  --sibling-index: 2;
}

ol:has(> :last-child:nth-child(2)) {
  --sibling-count: 2;
}

We could instead nest the --sibling-count rule inside the --sibling-index rule.

li:nth-child(2) {
  --sibling-index: 2;

  ol:has(> &:last-child) {
    --sibling-count: 2;
  }
}

While it may seem wacky to nest a parent inside its children, the following CSS code is completely valid; we are selecting the second li element, and inside, we are selecting an ol element if its second li element is also the last, so the list only has two elements. Which syntax is easier to manage? It’s up to you.

But that’s just a slight improvement. If we had, say, 100 elements we would still need to hardcode the --sibling-index and --sibling-count properties 100 times. Luckily, the following method will increase rules in a logarithmic way, specifically base-2. So instead of writing 100 rules for 100 elements, we will be writing closer to 10 rules for around 100 elements.

Flint and steel

This method was first described by Roman Komarov in October last year, in which he prototypes both tree counting functions and the future random() function. It’s an amazing post, so I strongly encourage you to read it.

This method also uses custom properties, but instead of hardcoding each one, we will be using two custom properties that will build up the --sibling-index property for each element. Just to be consistent with Roman’s post, we will call them --si1 and --si2, both starting at 0:

li {
  --si1: 0;
  --si2: 0;
}

The real --sibling-index will be constructed using both properties and a factor (F) that represents an integer greater or equal to 2 that tells us how many elements we can select according to the formula sqrt(F) - 1. So…

  • For a factor of 2, we can select 3 elements.
  • For a factor of 3, we can select 8 elements.
  • For a factor of 5, we can select 24 elements.
  • For a factor of 10, we can select 99 elements.
  • For a factor of 25, we can select 624 elements.

As you can see, increasing the factor by one will give us exponential gains on how many elements we can select. But how does all this translate to CSS?

The first thing to know is that the formula for calculating the --sibling-index property is calc(F * var(--si2) + var(--si1)). If we take a factor of 3, it would look like the following:

li {
  --si1: 0;
  --si2: 0;

  /* factor of 3; it's a harcoded number */
  --sibling-index: calc(3 * var(--si2) + var(--si1));
}

The following selectors may be random but stay with me here. For the --si1 property, we will write rules selecting elements that are multiples of the factor and offset them by one 1 until we reach F - 1, then set --si1 to the offset. This translates to the following CSS:

li:nth-child(Fn + 1) { --si1: 1; }
li:nth-child(Fn + 2) { --si1: 2; }
/* ... */
li:nth-child(Fn+(F-1)) { --si1: (F-1) }

So if our factor is 3, we will write the following rules until we reach F-1, so 2 rules:

li:nth-child(3n + 1) { --si1: 1; }
li:nth-child(3n + 2) { --si1: 2; }

For the --si2 property, we will write rules selecting elements in batches of the factor (so if our factor is 3, we will select 3 elements per rule), going from the last possible index (in this case 8) backward until we simply are unable to select more elements in batches. This is a little more convoluted to write in CSS:

li:nth-child(n + F*1):nth-child(-n + F*1-1){--si2: 1;}
li:nth-child(n + F*2):nth-child(-n + F*2-1){--si2: 2;}
/* ... */
li:nth-child(n+(F*(F-1))):nth-child(-n+(F*F-1)) { --si2: (F-1) }

Again, if our factor is 3, we will write the following two rules:

li:nth-child(n + 3):nth-child(-n + 5) {
  --si2: 1;
}
li:nth-child(n + 6):nth-child(-n + 8) {
  --si2: 2;
}

And that’s it! By only setting those two values for --si1 and --si2 we can count up to 8 total elements. The math behind how it works seems wacky at first, but once you visually get it, it all clicks. I made this interactive demo in which you can see how all elements can be reached using this formula. Hover over the code snippets to see which elements can be selected, and click on each snippet to combine them into a possible index.

If you crank the elements and factor to the max, you can see that we can select 48 elements using only 14 snippets!

Wait, one thing is missing: the sibling-count() function. Luckily, we will be reusing all we have learned from prototyping --sibling-index. We will start with two custom properties: --sc1 and --sc1 at the container, both starting at 0 as well. The formula for calculating --sibling-count is the same.

ol {
  --sc1: 0;
  --sc2: 0;

  /* factor of 3; also a harcoded number */
  --sibling-count: calc(3 * var(--sc2) + var(--sc1));
}

Roman’s post also explains how to write selectors for the --sibling-count property by themselves, but we will use the :has() selection method from our first technique so we don’t have to write extra selectors. We can cram those --sc1 and --sc2 properties into the rules where we defined the sibling-index() properties:

/* --si1 and --sc1 */
li:nth-child(3n + 1) {
  --si1: 1;

  ol:has(> &:last-child) {
    --sc1: 1;
  }
}

li:nth-child(3n + 2) {
  --si1: 2;

  ol:has(> &:last-child) {
    --sc1: 2;
  }
}

/* --si2 and --sc2 */
li:nth-child(n + 3):nth-child(-n + 5) {
  --si2: 1;

  ol:has(> &:last-child) {
    --sc2: 1;
  }
}

li:nth-child(n + 6):nth-child(-n + 8) {
  --si2: 2;

  ol:has(> &:last-child) {
    --sc2: 2;
  }
}

This is using a factor of 3, so we can count up to eight elements with only four rules. The following example has a factor of 7, so we can count up to 48 elements with only 14 rules.

This method is great, but may not be the best fit for everyone due to the almost magical way of how it works, or simply because you don’t find it aesthetically pleasing. While for avid hands lighting a fire with flint and steel is a breeze, many won’t get their fire started.

Using a flamethrower

For this method, we will use once again custom properties to mimic the tree counting functions, and what’s best, we will write less than 20 lines of code to count up to infinity—or I guess to 1.7976931348623157e+308, which is the double precision floating point limit!

We will be using the Mutation Observer API, so of course it takes JavaScript. I know that’s like admitting defeat for many, but I disagree. If the JavaScript method is simpler (which it is, by far, in this case), then it’s the most appropriate choice. Just as a side note, if performance is your main worry, stick to hard-coding each index in CSS or HTML.

First, we will grab our container from the DOM:

const elements = document.querySelector("ol");

Then we’ll create a function that sets the --sibling-index property in each element and the --sibling-count in the container (it will be available to its children due to the cascade). For the --sibling-index, we have to loop through the elements.children, and we can get the --sibling-count from elements.children.length.

const updateCustomProperties = () => {
  let index = 1;

  for (element of elements.children) {
    element.style.setProperty("--sibling-index", index);
    index++;
  }

  elements.style.setProperty("--sibling-count", elements.children.length);
};

Once we have our function, remember to call it once so we have our initial tree counting properties:

updateCustomProperties();

Lastly, the Mutation Observer. We need to initiate a new observer using the MutationObserver constructor. It takes a callback that gets invoked each time the elements change, so we write our updateCustomProperties function. With the resulting observer object, we can call its observe() method which takes two parameters:

  1. the element we want to observe, and
  2. a config object that defines what we want to observe through three boolean properties: attributes, childList, and subtree. In this case, we just want to check for changes in the child list, so we set that one to true:
const observer = new MutationObserver(updateCustomProperties);
const config = {attributes: false, childList: true, subtree: false};
observer.observe(elements, config);

That would be all we need! Using this method we can count many elements, in the following demo I set the max to 100, but it can easily reach tenfold:

So yeah, that’s our flamethrower right there. It definitely gets the fire started, but it’s plenty overkill for the vast majority of use cases. But that’s what we have while we wait for the perfect lighter.

More information and tutorials


How to Wait for the sibling-count() and sibling-index() Functions originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.



from CSS-Tricks https://ift.tt/weFDK0j
via IFTTT

No comments:

Post a Comment

Mostly Clear today!

With a high of F and a low of 12F. Currently, it's 22F and Fair outside. Current wind speeds: 7 from the West Pollen: 0 Sunrise:...