Published on

Experimenting with new widely available css functions sibling-index() and sibling-count()

sibling-index() and sibling-count() became widely available in August 2026, when Firefox added support. As the names suggest, sibling-index() returns an element's position among its siblings, and sibling-count() returns the total number of direct child elements, including the element on which it is used — both as plain numbers.

I wanted to see what these could do for cases that used to need JavaScript.

Browser support is still uneven — some demos below also lean on the newer if()/style() and relative colour syntax. For the smoothest experience, open the demos in a recent Chrome or Chromium-based browser. Elsewhere, each demo detects support at runtime and shows a plain message instead of a broken layout.

Show n chips with a "view more" count

/* How many chips stay visible before the list collapses. */
:root {
  --visible: 5;
}

/*
 * Registered so the values below compute to real integers. Unregistered custom
 * properties stay raw tokens, and a style() query can only compare computed
 * values — registering is what makes the if() rules work.
 */
@property --overflowing {
  syntax: '<integer>';
  inherits: false;
  initial-value: 0;
}

@property --overflow-count {
  syntax: '<integer>';
  inherits: false;
  initial-value: 0;
}

.chips li {
  /* 0 while the chip fits, 1 once it is past the cut-off. */
  --overflowing: clamp(0, sibling-index() - var(--visible), 1);

  display: inline-flex;

  /*
   * No selector can read sibling-index(), so the count becomes a number and
   * if() turns that number back into a keyword.
   */
  display: if(style(--overflowing: 1): none; else: inline-flex);

  /* Each chip enters a beat after the one before it. */
  animation-delay: calc((sibling-index() - 1) * 35ms);
}

/* The toggle: counts what is hidden, and hides itself when nothing is. */
.chips li.more {
  /* sibling-count() includes this <li>, hence the extra - 1. */
  --overflow-count: max(0, sibling-count() - var(--visible) - 1);

  display: inline-flex;
  display: if(style(--overflow-count: 0): none; else: inline-flex);
  counter-reset: overflow var(--overflow-count);
}

.chips li.more .show-more::before {
  content: '+' counter(overflow) ' more';
}

Try changing the --visible CSS variable and watch the component respond — including the "n more" count — with zero JavaScript involved.

JS here is only used to add new chips.

Notice it also uses the counter() CSS function to display the count. The difference: counter() returns a string, while sibling-count() returns a number — better suited when you want to compute with it rather than just display it.

Auto-generate chart colors

/*
 * Registered so the palette can be transitioned when the values change, and so
 * --saturation stays a real percentage rather than a raw token.
 */
@property --hue-base {
  syntax: '<number>';
  inherits: true;
  initial-value: 205;
}

@property --saturation {
  syntax: '<percentage>';
  inherits: true;
  initial-value: 55%;
}

.chart {
  /* The knobs the Randomize button turns. Everything below inherits them. */
  --hue-base: 205;
  --hue-step: 55;
  --saturation: 55%;

  transition:
    --hue-base 0.5s var(--ease),
    --saturation 0.5s var(--ease);
}

.group {
  /* Hue comes from the group's position among its sibling groups. */
  color: hsl(calc(var(--hue-base) + (sibling-index() - 1) * var(--hue-step)) var(--saturation) 45%);

  /* The axis label counts itself, so it can never disagree with the bars. */
  counter-reset: group sibling-index();

  &::after {
    content: 'G' counter(group);
  }
}

/*
 * sibling-index() restarts inside each group, so this ramps lightness per
 * series. Piping a sibling-index() value through a custom property and into a
 * descendant froze at the first group's value — setting the inherited `color`
 * above and deriving from currentColor here sidesteps that.
 */
.bar {
  background: hsl(from currentcolor h s calc(l + (sibling-index() - 1) * 20));
}

/* Same two formulas as .group — the legend cannot drift out of sync. */
.chart__legend li {
  color: hsl(calc(var(--hue-base) + (sibling-index() - 1) * var(--hue-step)) var(--saturation) 45%);
  counter-reset: group sibling-index();

  &::after {
    content: 'G' counter(group);
  }
}

Normally you'd pass an array of colors to your charting library, which assigns them by index — duplicating your theme in JS. With sibling-index(), that color logic can live entirely in CSS instead.

A table that grades on a curve

/*
 * A grade on a curve is a share of the class, not a score threshold. Which band
 * a row lands in depends on how many rows the table holds — so no selector can
 * decide it. The same third row is an A in a class of twenty and a B in a class
 * of eight, from identical markup.
 */

/*
 * Registered so the tier computes to a real integer: style() compares computed
 * values, and an unregistered property would still be the literal text
 * "round(down, …)". inherits: false keeps the positional value on the row —
 * piping one down through an inherited custom property freezes it at the first
 * element's value.
 */
@property --tier {
  syntax: '<integer>';
  inherits: false;
  initial-value: 0;
}

/*
 * The row works out a number; this renders it as a letter. system: fixed maps
 * the first symbol to 1, which is what the + 1 below is for.
 */
@counter-style grades {
  system: fixed;
  symbols: 'A' 'B' 'C' 'D' 'E';
  suffix: '';
  fallback: decimal;
}

.curve {
  /* The whole grading policy, in one token. */
  --bands: 4;

  --tint-a: color-mix(in srgb, var(--color-success, #16a34a) 20%, transparent);
  --tint-b: color-mix(in srgb, var(--color-info, #0ea5e9) 16%, transparent);
  --tint-c: color-mix(in srgb, var(--color-warning, #f59e0b) 16%, transparent);
  --tint-d: color-mix(in srgb, var(--color-error, #ef4444) 16%, transparent);
  --tint-e: color-mix(in srgb, var(--color-error, #ef4444) 28%, transparent);
}

tbody tr {
  /*
   * 0 for the top band of rows, --bands - 1 for the bottom, at any row count.
   * The largest value this can take is (n - 1) * bands / n, which stays below
   * bands for every n — so the result is already in range and needs no clamp,
   * and a one-row table divides by one rather than by zero.
   */
  --tier: round(down, (sibling-index() - 1) * var(--bands) / sibling-count(), 1);

  /* The fallback: a browser without if() drops the line below and stays plain. */
  background-color: transparent;
  /* prettier-ignore */
  background-color: if(
    style(--tier: 0): var(--tint-a);
    style(--tier: 1): var(--tint-b);
    style(--tier: 2): var(--tint-c);
    style(--tier: 3): var(--tint-d);
    else: var(--tint-e)
  );

  /* Nothing in the markup says "B". The row counts its way to the letter. */
  counter-reset: grade calc(var(--tier) + 1);
  transition: background-color 0.4s var(--ease);
}

.grade::before {
  content: counter(grade, grades);
}

This one shows that even semantic coloring — grading each cell relative to its siblings — can be done in pure CSS.