$[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.
.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#
.list > li {
animation: rise .5s both;
animation-delay: calc($[index] * 80ms);
}
@keyframes rise { from { opacity: 0; translate: 0 1rem; } }Color ramp#
.swatches > span { background: hsl(calc($[index] * 36) 80% 60%); }Start counting at 1#
Pass &self and a starting number:
.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:
#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:
.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.