$[index]: per-element values

$[index] is replaced with each matched element's position, counting from 0. One rule can give fifty slides fifty different background images, stagger an animation across a list, or rotate colors, without writing a rule per element.

CSS
.slider > div { background-image: url(/img/slide-$[index].jpg); }

If the slider has four slides, they get slide-0.jpg, slide-1.jpg, slide-2.jpg and slide-3.jpg. Add a fifth slide in your HTML and it gets slide-4.jpg, with no CSS change.

Why not native CSS?#

Since 2026, native CSS has sibling-index() in all current browsers. It returns a number you can use in calc(), which covers some of the same ground. It cannot build a URL: url() does not accept functions or variables, so url(/img/slide-sibling-index().jpg) is not possible. $[index] is substituted as text, so it works anywhere: in url(), in content, in grid lines and in selectors' values.

sibling-index() $[index]
Use inside url() and content No Yes
Counts Position among siblings, from 1 Order among all matched elements, from 0 by default
Works in older browsers No Yes

Examples#

Staggered animation#

CSS
.list > li {
  animation: rise .5s both;
  animation-delay: calc($[index] * 80ms);
}
@keyframes rise { from { opacity: 0; translate: 0 1rem; } }

Color ramp#

CSS
.swatches > span { background: hsl(calc($[index] * 36) 80% 60%); }

Start counting at 1#

Pass &self and a starting number:

CSS
.steps > li::before { content: "Step $[index(&self,1)]"; }

Image sequences in several places#

The index counts in document order across every element the selector matches. Two galleries matched by .gallery img share one sequence, 0 to n. To number each gallery from 0, give each its own selector:

CSS
#summer img { background-image: url(/img/summer-$[index].jpg); }
#winter img { background-image: url(/img/winter-$[index].jpg); }

More built-in values#

Value Gives Example
$[count] How many elements the rule's selector matches, across the whole page .cols > div { width: calc(100% / $[count]); }
$[random(1,5)] A random number per element, kept for as long as the page is open. Whole numbers when both limits are whole. .hero { background-image: url(/img/hero-$[random(1,5)].jpg); }
$[attr(data-bg)] The element's own attribute value, as text .card { background-image: url($[attr(data-bg)]); }
$[year] The current year footer::after { content: "© $[year]"; }

Like $[index], they work in any property, including url() and content, and they are recalculated after the page loads and when the window resizes. Each element keeps its own $[random()] value. Native CSS can't do this: url() won't accept attr() or random(), and native random() is only in Safari and Chrome 156+.

How it works#

css-pp finds the elements that match the rule's selector, gives each a stable data-css-i attribute and writes one rule per element:

Output
.slider > div[data-css-i="1"]{background-image:url(/img/slide-0.jpg)}
.slider > div[data-css-i="2"]{background-image:url(/img/slide-1.jpg)}
.slider > div[data-css-i="3"]{background-image:url(/img/slide-2.jpg)}

This is accurate however the markup is arranged: a heading before the slides, slides wrapped in <figure> elements, several containers, or a selector list. The attribute selector has the same specificity as :nth-child(), so your cascade doesn't change. Indexes are recalculated after the page loads and when the window is resized. That matters when the stylesheet is opened in <head>, before the elements exist.

Version note. Before 3.0, $[index] was mapped to :nth-child() positions. That gave wrong results when the matched elements weren't consecutive children of a single parent. 3.0 uses the stable attributes described above.