Skip to content

Random

CSS has no way to give each element its own random number today, so organic variation gets faked with long :nth-child() ladders of hand-picked values. random (element) writes three independent rolls — --const-random, --const-random-2, --const-random-3, each a float in [0, 1) — onto every element you bind it to, once. One CSS rule then reads them and no two elements look alike.

unseeded · Math.random()

seeded · randomSeed: 42

roll 1

Every bar is bound to random, which writes --const-random, --const-random-2 and --const-random-3 on it, once. CSS spends them: height from the first, tint from the second, breathe phase from the third. Press Re-roll both — the left field is new every time; the right one, seeded, returns the same skyline. Reload the page: the right field is unchanged, the left is a stranger.

Bind the elements you want varied; each gets its own rolls, and they inherit to descendants like any custom property:

<script type="module">import 'prop-for-that/auto'</script>
<div class="tile" data-props-for="random"></div>
<div class="tile" data-props-for="random"></div>
<div class="tile" data-props-for="random"></div>
.tile {
/* height, tint and phase — one rule, a different result per tile */
block-size: calc(12% + var(--const-random, 0.5) * 88%);
background: color-mix(in oklab, tomato calc(var(--const-random-2, 0.5) * 100%), gold);
animation: breathe 2.4s ease-in-out infinite alternate;
animation-delay: calc(var(--const-random-3, 0.5) * -2.4s);
}

Three rolls, because variation usually needs more than one axis and CSS can’t derive a second independent random from the first. Everything past that is derivable, so it isn’t shipped:

.jitter {
rotate: calc((var(--const-random) - 0.5) * 20deg); /* signed spread */
translate: calc(var(--const-random-2) * 100% ) 0; /* 0 → 100% */
animation-duration: calc(2s + var(--const-random-3) * 3s); /* a range */
--bucket: round(down, calc(var(--const-random) * 5), 1); /* 0 → 4 */
--coin: calc(sign(var(--const-random-2) - 0.5)); /* -1 or 1 */
}

The rolls are const cadence, and random opts out of viewport gating — a gated element source re-runs on every viewport re-entry, which for a generator would mean a fresh value each time the element scrolled back into view, mid-animation. Bind once, and the number an animation started with is the number it keeps.

Pass a seed and the rolls stop being random-per-load without stopping being varied-per-element:

import { configure, propsFor, register } from 'prop-for-that'
import { random } from 'prop-for-that/plugins'
register(random)
configure({ randomSeed: 42 })
propsFor(document.querySelectorAll('.tile'), ['random'])

Under auto it’s an attribute on the root <html> — no JS of your own at all:

<script type="module">import 'prop-for-that/auto'</script>
<html data-props-seed="42">
<div class="tile" data-props-for="random"></div>
</html>

Each element’s rolls are derived from the seed plus its position in the DOM, not from a shared sequence. So:

  • the same markup renders the same scatter on every load — good for SSR, screenshot diffing, and print;
  • rebinding an element hands it the values it had before, rather than a new set;
  • bind order doesn’t matter, but tree order does — insert an element in the middle and everything after it reshuffles.

Unset (the default), rolls come from Math.random(). The seed is read when a source starts, which is how the demo above runs one unseeded field and one seeded field on the same page: flip randomSeed between the two propsFor() calls.

Native random() (CSS Values 5, with per-element caching) is the eventual answer to all of this, and it’s beginning to ship. Until support is broad enough to rely on, this plugin covers the same ground in every engine — and its values, being custom properties, can also be read by @container style() and inherited, which a bare random() call can’t.