Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[css-values] Proposal: add sibling-count() and sibling-index() #4559

Closed
argyleink opened this issue Dec 4, 2019 · 73 comments
Closed

[css-values] Proposal: add sibling-count() and sibling-index() #4559

argyleink opened this issue Dec 4, 2019 · 73 comments

Comments

@argyleink
Copy link
Contributor

argyleink commented Dec 4, 2019

Problem

Currently we can query a child based on it's child index position or query on children length (with some complex syntax), but we can't use the index or length as values in our styles. Feels like they're known, but not accessible.

so we end up doing stuff like this:

.el {
  --delay: calc(var(--i, 1) * 400ms);
  animation: fadeIn 1000ms var(--delay) forwards;
  width: 100px;
  height: 100px;
  background: darkorchid;
  margin: 20px;
  opacity: 0;
}

.el:nth-child(2) {
  --i: 2;
}

.el:nth-child(3) {
  --i: 3;
}

@keyframes fadeIn {
  100% { opacity: 1; }
}

above source from Codepen


Proposal

2 new functions: sibling-count() and sibling-index(). These should report values based on element count, not node count.

sibling-count()

This function returns the total length of sibling elements as a number, similar to node.childElementCount (docs) or node.children.length but accessible from a sibling. Consider this like a child asking "how many siblings they have".

example usage

Dynamic background color that changes based on total children in the list

ul > li {
  background-color: hsl(sibling-count() 50% 50%);
}

sibling-index()

This function returns the contextual child index as a number. Similar to the value queried with nth-child, this would be a get() call for the contextual child's index in the tree. Consider this like a child asking "what position am I in this family".

example usage

Dynamic background color that changes based on child index position, resulting in a stepped gradient effect

ul > li {
  background-color: hsl(calc(sibling-index() * 10) 50% 50%);
}

All Together

Example code is a slightly modified version of Jake Archibald's comment here 👍

ul > li {
  /* Stagger from start to end */
  animation-delay: calc(sibling-index() * 100ms);

  /* Stagger from end to start */
  animation-delay: calc(sibling-count() - sibling-index() * 100ms);
}



Use Cases

  • staggered animations: example1 example2
  • dynamic & contextual color systems
  • dynamic & contextual spacing
  • dynamic & contextual distance
  • etc

splitting


Conclusion

Most other proposals for similar functionality, request access to counters() as a unit that can be passed to calc(). But I feel that is overloading the counters feature and is rooted in a mindset of leveraging something "close" to what is needed, where what's actually wanted is the child index position for visually reasonable and meaningful UI feedback and presentation.

By proposing a solution that doesn't involve counters, I hope to bypass much of the pain points associated with the feature to help unblock the large and ever-growing set of use cases that could leverage these contextual values.

Sources & Chatter

#1869
#1176
#1026
https://www.w3.org/TR/selectors-4/#child-index
https://twitter.com/shshaw/status/1201978228375724032?s=20
https://twitter.com/smfr/status/1202276694230306816?s=20 @smfr

@shshaw
Copy link

shshaw commented Dec 4, 2019

Definitely on board for this. These functions would pave the way for making Splitting.js obsolete and enable some really great animations and effects.

Small point for discussion: Should sibling-count() be the count of all the parent's children OR the count of this element's siblings (practically parent.children.length - 1)? Because "semantically" if you're asking for the count of siblings, it would not include the element itself. In plain language: "I have one brother" versus "My parents have two sons"

@jakearchibald
Copy link
Contributor

Proposal looks good, although it'd be nice if an element could know it's children count too.

@jakearchibald
Copy link
Contributor

I'm not sure why we have two issues though. Feels like one of them should be closed.

@smfr
Copy link
Contributor

smfr commented Dec 4, 2019

Maybe there should also be a depth query (with some consideration for what happens in shadow trees).

@AmeliaBR
Copy link
Contributor

AmeliaBR commented Dec 4, 2019

This looks like a straight duplicate of #1869, can we close this & copy the discussion over there?

That conversation discusses the counter-value() as one possible solution, but isn't restricted to it.

@argyleink
Copy link
Contributor Author

Tab and I felt a new one was due since there were a few issues that alluded to similar functionality but generally had counters() involved or weren't as minimal / specific as this. That's why the issues are linked that helped lead to this one. BUT, I'm ok moving it over to another thread if we want!

@emilio
Copy link
Collaborator

emilio commented Dec 4, 2019

Why a function?

(With an implementor hat on) it's a bit unfortunate having to do the element instead of node count, as the first is O(N) but the second is O(1)..

@argyleink
Copy link
Contributor Author

What could we use that's not a function @emilio!?

Element vs Node, browser internals don't do any distinguishing that you can piggyback on? 0(N), even if the children are fixed and there's no recursion required? tell me more, this sounds interesting 🙂

@emilio
Copy link
Collaborator

emilio commented Dec 4, 2019

Well, I was thinking there's nothing really preventing you from using an ident or such (functions without arguments are not great), but I guess that would make some properties that can take <ident> and <number> or <integer> ambiguous. Also I guess I could see sibling-count(<selector>) or something being a thing...

Element vs Node, browser internals don't do any distinguishing that you can piggyback on? 0(N), even if the children are fixed and there's no recursion required? tell me more, this sounds interesting slightly_smiling_face

At least Gecko keeps the node children count in the parent node (here), so getting the sibling node count is just parentNode.childCount - 1 which is pretty fast. For elements, we'd have to actually traverse all children and check they're elements.

There's nothing preventing us from tracking element count either, but it grows every node (or at least container node) which is not great.

But it seems other engines don't do the same optimization as Gecko, so feel free to take that point as an implementation detail...

@Crissov
Copy link
Contributor

Crissov commented Dec 5, 2019

This feels like it should be solved either with predefined counters or with predefined variables that do not have a double-hyphen prefix.

  • var(n) or var(child) or var(child-n) for the index value used for :nth-child(), which is calc(var(preceding-siblings) + 1), i. e. 1 for :first-child and for :only-child, where var(siblings) = 0
  • var(m) or var(last-child) or var(-child) or var(child-m) for the index value used for :nth-last-child(), which is calc(var(succeeding-siblings) + 1) or calc(var(siblings) - var(child) + 2), i. e. 1 for :last-child and for :only-child, where var(siblings) = 0 and var(child) = 1
  • var(i) or var(type) or var(type-n) for the index value used for :nth-of-type()
  • var(j) or var(last-type) or var(-type) or var(type-m) for the index value used for :nth-last-of-type()

https://drafts.csswg.org/selectors-4/#child-index

@faceless2
Copy link

faceless2 commented Dec 5, 2019

I'd been trying to work up a coherent way to do this using counter() and target-counter() for some time, but I've (somewhat grudgingly) come to the conclusion that a new function is a better option - it avoids all sorts of hazards inherent in counters, which are considerably more complex then they first appear (when it comes to scoping).

I would personally lean towards a new function over a syntax that was a) not a function or b) used var(), because it leaves more room for expansion in the future: it's easy enough to define the sibling-index() function now, with no arguments, and get that implemented. If users demand more in the future, you can supply arguments (e.g. a selector as mentioned by @emilio). You would struggle to defined that cleanly with a var().

Can I suggest that instead of sibling-index() and sibling-count() we consider index(sibling) and count(sibling) - because you can guarantee there will be situations where you want the count of your children rather than siblings, for example to solve the issue from #4211:

ol[reversed] {
    counter-reset: list-item count(children);
}

It would be good to leave that option available in the syntax, even if it's not implemented yet.

(edit: see also #4181)

Idle musing on other uses:

.clockhand {
    position: absolute;
    left: calc(50% + sin(index(sibling) / count(sibling)) * var(--radius));
    top: calc(50% + cos(index(sibling) / count(sibling)) * var(--radius));
}

@jakearchibald
Copy link
Contributor

In #1869 (comment) I showed that if you can get a children count, you can use that to get a sibling count, so if we need an minimum viable product, then I'd rather have a children count than sibling count.

I also think "sibling count" is misleading. For example, I am one of two children, but doesn't that mean I have one sibling? Whereas here we're using "sibling count" to mean "sibling count + myself".

@faceless2
Copy link

How about index(element) and/or index(node)? It covers the point made by emilio above.

@jakearchibald
Copy link
Contributor

jakearchibald commented Dec 5, 2019

I don't mind too much between index(element) vs index-element().

CSS doesn't really allow you to interact with non-element nodes so adding something like index(node) would be pretty new/unusual.

@shshaw
Copy link

shshaw commented Dec 5, 2019 via email

@jakearchibald
Copy link
Contributor

count(siblings) for getting parent’s element count minus 1.

Does anyone have a use-case where "parent's element count minus 1" is the number you actually want, rather than "parent's element count"?

@shshaw
Copy link

shshaw commented Dec 5, 2019

@jakearchibald child count - 1 is a value I use in Splitting demos frequently for getting a “percentage” like decimal (range from 0 to 1 of the child’s index relative to the total). Here's a quick example from the initial sibling-count() discussion where I use that "child count - 1" value to stagger an animation-delay over the course of 2s.

animation-delay: calc( 2s * ( var(--sibling-index) / var(--sibling-count) ) );

https://codepen.io/shshaw/pen/LYEEKMQ

This can of course be achieved by doing child-count() - 1, but if we're calling it the count of the siblings then it seems odd that it would include the element itself. I do find the - 1 value very useful in my instances, but I'm not beholden to a - 1 value if there's a strong case against, or if this is just a difference in semantics.

@Loirooriol
Copy link
Contributor

animation-delay: calc( 2s * ( var(--sibling-index) / var(--sibling-count) ) );

This assumes that the index is 0-based. I would prefer it to be 1-based for consistency with nth-child.
Also, be aware that if you only have 1 child, you will have a 0/0 division.
I would argue that index-1-based / parent-children-count would be safer, and not getting 0 for the 1st child doesn't seem a big problem.

@shshaw
Copy link

shshaw commented Dec 5, 2019 via email

@tomhodgins
Copy link

I can immediately see the value of both sibling-count() and sibling-index() and other related ideas too; in the past I've applied two similar concepts to styling:

  • index of this tag amongst its siblings (the children of its parentElement)
  • index of this tag amongst the list of tags in the document matching a given selector (similar to index inside querySelectorAll results)

For the second idea I've made a plugin to help in the past, though now when I want to use this concept I usually reach for CSS custom properties and a little JS for a cleaner approach. Having the awareness of a tag's index inside parentElement.children or document.querySelectorAll(selector) in CSS natively, as well as the parentElement.children.length and/or el.children.length could simplify lots of existing tricky styles that have been built in much more complex ways!

@tabatkins
Copy link
Member

So I agree with the proposed set of four functions: sibling-index(), sibling-count(), child-count(), and tree-depth().

concern about "sibling" naming

I get the concern on precision, but I don't know of another word covering the concept, and "sibling" can be used in this sense (like "I'm one of three siblings"). I think it's the most reasonable name. I do not like being less specific with something like count() or index().

node vs element

Node count is just so, so un-useful. ^_^ But also, having these functions disagree with the :nth-child() selectors would be a terrible idea imo.

function vs keyword

Hm, yeah, could go either way. I don't plan on extending the functions to more stuff, so I guess keywords could work. I'm slightly wary of adding "keywords that can be used anywhere" because of the potential syntax conflicts (such as animation-name in the 'animation' property); functions avoid that. (We should have ensured that author-defined names were syntactically distinguishable in all cases earlier, but that's a legacy mistake.) If these were usable only in calculations (that is, you have to wrap it in a calc() or other math function), my concern would be alleviated.

Tho the suggestion to later extend this to allow a selector, a la the :nth-child(... of <selector>), is a reasonable future extension point, and thus a decent argument for function over keyword.

1-index vs 0-index

Definitely 1-indexed, just like :nth-child(). Diverging from :nth-child() would be a terrible mistake.

count of siblings vs count of "siblings other than me"

I think the concept is a lot cleaner to express and understand if it's just "how many children there are"; both "sibling count" and "child count" should agree. Some cases definitely want "siblings other than me", but that's trivial to do in a calc(), and you're probably already using a calc() anyway. (The given example is, to do a division as well.)

count/index across the entire document

Not planning on doing this; it would be super expensive. Use-cases are small enough, as far as I'm aware, that I'm happy to leave that to JS for decorating the element with a value.

using var() with predefined (not --prefixed) keywords

This would be a misuse of the var() syntax, and have unfortunate implications regarding parsing; we wouldn't be able to reject a color: children-count() even tho the function can only ever return integers.

@Crissov
Copy link
Contributor

Crissov commented Dec 7, 2019

I always expected that var() would eventually be extended to support predefined variables without a double-hyphen prefix (like env()), because otherwise that ugly convention could have been avoided altogether, so I donʼt see why it would be syntax misuse, but I do understand the type-casting implications, which is why predefined counters seem like a more natural fit for inherently integer variables – alas, they have other problems.

Anyway, selectors in property values always seem like a bad idea, so Iʼm against a proactive function syntax without parameters, which probably appears natural only to a programmerʼs brain. In pseudo-classes, there are already precedents of something like :foo paired with :foo(bar) if this soothes those concerns.

@myfonj
Copy link

myfonj commented Jan 6, 2020

Current state of proposed sibling-index / tree-depth functions FMPoV lacks (or fails to clearly show capability of) quite desired features compared to what counter-increment can do already:

  • skip some siblings,
  • fine grain what exactly participates on the resulting value: e.g. take attributes or nesting level into account.

Both are possible using counter-increment and standard selectors, as illustrated.
Counters displayed in content are meant to be available for other CSS functions or custom properties:

/* fig.1: skip hidden <tabs><tab>(1)</tab><tab hidden>(skipped)</tab><tab>(2)</tab></tabs> */
tabs { counter-reset: index; }
tabs > tab:not([hidden])::before { counter-increment: index; content: 'Index: ' counter(index); }

/* fig.2: nesting depth <p><em>(1)<em>(2)<em>(3)</em></em><em>(2)</em></em></p> */
p { counter-reset: depth; }
p em::before { counter-increment: depth; content: 'Depth: ' counter(depth); }
p em::after { counter-increment: depth -1; content: ''; }

I think above use cases are quite relevant for problem in question. Unless current proposal will be expanded so that

  • problem depicted in fig.1 could be solved with something like
    tabs > tab{--index: sibling-index(:not([hidden])); }
    (function argument filters previous siblings),
  • and problem from fig.2 with something like
    p em{--depth: tree-depth(:has(> em)); }
    (function argument filters parents chain),

(yes, using selectors in properties feels super weird), or any other way, I'd rather lean towards #1026.


Please pardon unasked intervention and errors; I don't follow all conversations around here, so this had very likely been discussed before; I just felt it should be mentioned here.

@Loirooriol
Copy link
Contributor

@myfonj While counters can be more flexible, they have some complex inheritance. In order to know the value of a counter in an element, you may have to iterate all the descendants of the previous siblings of the element. And this can be bad for parallelization, see #1026 (comment). So it seems less doable to me.

@jonathantneal
Copy link
Contributor

Would order affect sibling-index? The scenario I’m imagining is a series of cards with different sorts at different breakpoints. Example @ https://codepen.io/jonneal/pen/yLNyVjJ

@Loirooriol
Copy link
Contributor

@jonathantneal That would create a circularity, because you should be able to use sibling-index in order, or in a custom property referenced by order.

@tabatkins
Copy link
Member

Yeah, the functions would definitely just be counting based on DOM, exactly the same as :nth-child().

@jonathantneal
Copy link
Contributor

I appreciate and agree with the responses to my last question. Thank you!


Might these related functions be folded into one?

Borrowing from an earlier example:

/* stagger the animation, start to end */
.animate > g {
  animation-delay: calc(this(sibling-index) * 100ms);
}

/* stagger the animation, end to start */
.animate > g {
  animation-delay: calc(this(sibling-count) - this(sibling-index) * 100ms);
}

I experienced slowness reading sibling-index() as a query of the current element and not a sibling. My sense of context tanked after I read the comment about future extensions that might look something like sibling-index(of <selector>). I felt clarity when writing out this(sibling-index) and this(sibling-index of <selector>).

Anyway, this is my own experience I’m sharing in case other people feel the same clarity after seeing the above example.

@Loirooriol
Copy link
Contributor

@kbrilla See #4559 (comment) and #4559 (comment)

@kbrilla
Copy link

kbrilla commented Aug 29, 2023

@kbrilla See #4559 (comment) and #4559 (comment)

thx, I was looking for reading-order and missed order, I understand that reading-index() would also include circularity?

@jakearchibald
Copy link
Contributor

@kbrilla yep, for the same reason

@jakearchibald
Copy link
Contributor

jakearchibald commented Nov 7, 2023

I still think it's pretty weird that we're using "sibling count" to mean "number of children the parent has".

@Que-tin
Copy link

Que-tin commented Nov 7, 2023

I still think it's pretty weird that we're using "sibling count" to mean "number of children the parent have".

Agree, isn't sibling-count wrong anyway from a language standpoint? Don't we mean smth. like sibling-count including myself / total-sibling-count?
In common parlance you wouldn't include yourself when talking about siblings right? (non native speaker)

@Loirooriol
Copy link
Contributor

However, I think it's more useful to include the current element when counting, and sibling-count-including-self() is too cumbersome to write. And as Tab said, you can say "I'm one of three siblings"

@Que-tin
Copy link

Que-tin commented Nov 7, 2023

@Loirooriol And what about child-index()? I mean we call them Child-indexed Pseudo-classes as well, wouldn't this make more sense?

@myfonj
Copy link

myfonj commented Nov 7, 2023

BTW, IIUC current proposal suggests that sibling-index() of the first child will be 1, right? Are everybody OK calling "nth-child-ness" starting at 1 "index" [1]?

And also, is the consensus about mechanism for "skipping" items that it will leverage the ".. of" notation:
I.e. to set for example animation-delay of "visible" (:not([hidden])) items to 0ms, 100ms, 200ms etc, we will do:

li {
  animation-delay: calc( (sibling-index(of :not([hidden])) - 1) * 100ms );
}

Is it correct?


[1] "Index" being term that majority [citation needed] of modern programming languages use for zero-based notations? I have nothing against it, just liked how CSS avoided [2] the list-item-indices-start-at-one ("FORTRAN") camp with that clever naming of nth-child.
[2] If we forget about z-index that technically starts at "C"'s ("BCPL") zero, but is not list item "index" in a sense, since it can be negative.

@jakearchibald
Copy link
Contributor

And as Tab said, you can say "I'm one of three siblings"

That's fair. I missed that comment.

@Loirooriol
Copy link
Contributor

@Que-tin I guess prefixing both function with sibling- makes it clear that they are related?

@myfonj Yes, I want 1-indexing, to be consistent with :nth-child. Currently these functions don't accept a selector, but it seems a clear extension (to be discussed in another issue).

@tabatkins
Copy link
Member

We already bikeshedded the names. child-*() is out - it reads as referring to the element's children (especially in child-count()). sibling-*() is fine, it's somewhat ambiguous in English but there's no way to be more specific without an absurdly long name.

And for indexes, CSS always 1-indexes, and specifically :nth-child() does, which this clearly mirrors. There was never any possibility we would do anything different.

@argyleink
Copy link
Contributor Author

I'm excited for this to land, I'm starting to see more examples of :has() to do the task

ol {
  &::after {
    display: block;
    margin-block-start: 1rem;
    font-size: 2rem;
  }
  
  &:has(> li:last-child:nth-child(1))::after {
    content: 'sibling-count() = 1';
  }
  
  &:has(> li:last-child:nth-child(2))::after {
    content: 'sibling-count() = 2';
  }
  
  &:has(> li:last-child:nth-child(3))::after {
    content: 'sibling-count() = 3';
  }
  
  &:has(> li:last-child:nth-child(4))::after {
    content: 'sibling-count() = 4';
  }
  
  &:has(> li:last-child:nth-child(5))::after {
    content: 'sibling-count() = 5';
  }

  /* ... */
}

@cdoublev
Copy link
Collaborator

These functions may, in the future, be extended to accept an of <selector> argument, similar to :nth-child(), to filter on a subset of the children.

If I am not mistaken, these functions are currently the only functions that have no argument value definition.

From a theoretical point of view, I do not know if an "empty" value definition matches an empty input but from a pratical point of view, an empty value definition is not great, and it seems more appropriate to define these functions as keywords if they are not intented to accept arguments in the future.

@Loirooriol
Copy link
Contributor

Well, we have the functional :is() selector that can be empty (but this may be dropped in #8356).

And I do think these should be extended with a selector argument, see #9572

@cdoublev
Copy link
Collaborator

Its argument value definition is not empty. Basically, for a CSS parser that uses syntaxes extracted from the specs, supporting an empty value definition only represents an additional check. So it's not a big deal.

With some hindsight, I think they are appropriate if they represent dynamic values, ie. if the styles depending on them are applied when sibling elements are added/removed.

@myfonj
Copy link

myfonj commented Mar 15, 2024

[...] it seems more appropriate to define these functions as keywords if they are not intended to accept arguments in the future.

Well sibling-index without ability to do sibling-index( *:not([hidden]) ) would be pretty much useless *.

If the consistency with current "draconic" :is() is crucial, then enforcing explicit * for sibling-index( * ) to avoid invalid (missing) argument is an option. It is true that it would make the purpose of the argument more clear, perhaps, but at this point I don't see it as a strong argument.


*: such simple mechanism we can already emulate with custom properties and bunch of stupid repetition: *:nth-child(1) { --child-index: 1; } *:nth-child(2) { --child-index: 2; } /* ad nauseam */ for calc() and/or ..-index: "1" for content .

@Link2Twenty
Copy link

Another use case for sibling count would be gaining the ability to do snake wrap style grids as mentioned in #10100

example

@Link2Twenty
Copy link

Link2Twenty commented Apr 22, 2024

With the names, are we settled on sibling now? Would repeating the nth syntax here lead to confusion with the pseudo-classes?

I was thinking something like nth-current and nth-count would make sense if you're familiar with the nth pseudo-classes.

<div class="grid">
  <div class="item"></div>
  <div class="item"></div>
  <div class="item"></div>
  <div class="item"></div>
  <div class="item"></div>
  <div class="item" hidden></div>
  <div class="item count"></div>
  <div class="item"></div>
  <div class="item"></div>
  <div class="item"></div>
  <div class="item"></div>
  <div class="item"></div>
  <div class="item"></div>
</div>
.item::after {
  content: nth-current(); /* for .count -- 7 */
  content: nth-count(); /* for .count --  13 */
}

.count::after {
  content: nth-current(); /* for .count -- 1 */
  content: nth-count(); /* for .count -- 1 */
}

.item:not([hidden]) {
  content: nth-current(); /* for .count -- 6 */
  content: nth-count(); /* for .count -- 12 */
}

.item:not([hidden]) {
  /* gradually get more saturated as you reach the last item */
  background-color: hsl(9 calc(nth-current() / nth-count() * 100%) 64%);
}

@iahu
Copy link

iahu commented Oct 12, 2024

a correlation problem is that, can we make a pair functions for sibling on one (column or row direction) layout-line, like line-sibling-count() and line-sibling-index().

the "layout-line" is some elements layout on one line after rendered, like some words make a text line.

@Loirooriol
Copy link
Contributor

@iahu This would basically suffer from the same problems as https://wiki.csswg.org/faq#selectors-that-depend-on-layout

For example, you could use line-sibling-index() in the order property, causing circularities.

@iahu
Copy link

iahu commented Oct 12, 2024

@iahu This would basically suffer from the same problems as https://wiki.csswg.org/faq#selectors-that-depend-on-layout

For example, you could use line-sibling-index() in the order property, causing circularities.

You are right, this may cause circular problem.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests