DEV Community

Cover image for I Replaced Math.random() With CSS. Here Is What Works.
Andrés Clúa
Andrés Clúa

Posted on

I Replaced Math.random() With CSS. Here Is What Works.

Why I care about this

I build creative sites for a living. Almost every project has some small random detail. Cards that sit a bit crooked. Particles that start at different times. A hue that changes on every load.

For years, that meant the same ten lines of JavaScript. Loop over the elements, call Math.random(), write a CSS variable. It works. But it runs after the HTML loads, it needs a script, and it fights with my server rendering.

On October 20, Google shipped Chrome 156. It includes the CSS random() function. Safari already had it. So I spent a few evenings replacing my JavaScript with plain CSS.

This post is what I learned. What works, what does not, and the one thing that will confuse you if you read older tutorials.

The basic syntax

random() takes a minimum and a maximum. It returns a value between them. You can add a third value, a step, to snap the result to fixed increments.

.card {
  rotate: random(-4deg, 4deg);           /* any angle in the range */
  width: random(200px, 320px, 40px);     /* 200, 240, 280 or 320px */
  animation-delay: random(0s, 2s);
}
Enter fullscreen mode Exit fullscreen mode

Three rules to remember, all from MDN:

  1. Min, max and step must be the same data type. Lengths with lengths, angles with angles. Units can differ (1em and 40vw is fine).
  2. The range is inclusive on both ends.
  3. If max is smaller than min, you get the min value. No error, no warning. It works anywhere CSS accepts a length, angle, time, number, percentage, integer, frequency or resolution. That covers most of what I randomize.

The part that confused me: who shares the value

By default, every random() call is independent. Every element, every property, every position in a shorthand gets its own value. Most of the time that is what you want.

But sometimes you need a square. Width and height must be random, but equal. That is what the optional first argument is for. MDN calls it the <random-key>.

Key Same value across elements? Same value across properties?
auto (default) No No
element-scoped No Yes
property-scoped Yes No
--my-name Yes Yes
--my-name element-scoped No Yes, for calls with that name
fixed 0.5 You pick the seed (0 to 1) Yes

So a set of random squares, each one a different size, looks like this:

.tile {
  width: random(--tile-size element-scoped, 60px, 140px);
  height: random(--tile-size element-scoped, 60px, 140px);
}
Enter fullscreen mode Exit fullscreen mode

Here is the trap. Older articles, including the WebKit announcement and early Frontend Masters tests, use a keyword called element-shared. The spec has moved on. The current names are element-scoped, property-scoped and property-index-scoped. If you copy a snippet from 2025 and nothing happens, check this first.

One more gotcha. A normal custom property does not store a random result. --size: random(1px, 100px) is text substitution, so every var(--size) rolls again. If you want one stored value, register the property with @property. MDN explains this with a short example.

Example 1: a messy stack of cards

This is a pattern I use a lot. A grid of photos or cards that look like they were dropped on a table. Each one tilted and shifted a little.

Here is the JavaScript version I have shipped many times:

const cards = document.querySelectorAll('.card');

function randomBetween(min, max) {
  return Math.random() * (max - min) + min;
}

cards.forEach((card) => {
  card.style.setProperty('--tilt', `${randomBetween(-6, 6)}deg`);
  card.style.setProperty('--shift-x', `${randomBetween(-12, 12)}px`);
  card.style.setProperty('--shift-y', `${randomBetween(-12, 12)}px`);
  card.style.setProperty('--delay', `${randomBetween(0, 0.6)}s`);
});
Enter fullscreen mode Exit fullscreen mode
.card {
  rotate: var(--tilt, 0deg);
  translate: var(--shift-x, 0) var(--shift-y, 0);
  animation: drop 0.5s var(--delay, 0s) both;
}
Enter fullscreen mode Exit fullscreen mode

It works. But the cards render straight, then jump when the script runs. With server rendering you also get a mismatch between server and client values.

Here is the CSS version:

.card {
  rotate: random(-6deg, 6deg);
  translate: random(-12px, 12px) random(-12px, 12px);
  animation: drop 0.5s random(0s, 0.6s) both;
}
Enter fullscreen mode Exit fullscreen mode

No script. No flash. Each card gets its own values because the default key is auto. The two values inside translate are also independent, because auto separates calls by their position in the declaration.

If you add the step argument, you get a more designed feel. Tilts of exactly -6, -3, 0, 3 or 6 degrees look less random and more intentional:

.card {
  rotate: random(-6deg, 6deg, 3deg);
}
Enter fullscreen mode Exit fullscreen mode

Example 2: a palette that changes on every load

Pure random color looks bad. Fast. What I usually want is one random hue for the whole page, and small variations per element. That is where the keys pay off.

.blob {
  /* One hue for the whole page: a named key alone is global */
  --hue: random(--page-hue, 0, 360);

  /* Lightness varies per element, in steps of 10 */
  background: hsl(var(--hue) 70% random(35%, 75%, 10%));

  /* Each blob is a circle of its own size */
  width: random(--blob-size element-scoped, 80px, 240px);
  height: random(--blob-size element-scoped, 80px, 240px);

  /* Scatter them across the hero */
  position: absolute;
  top: random(0%, 90%);
  left: random(0%, 90%);
}
Enter fullscreen mode Exit fullscreen mode

Every load gives you a new mood. But it always looks like one palette, because every blob uses the same --page-hue seed.

The JavaScript version needed one value for the page and one loop for the elements. Something like this:

const hue = Math.floor(Math.random() * 361);
document.documentElement.style.setProperty('--hue', hue);

document.querySelectorAll('.blob').forEach((blob) => {
  const size = 80 + Math.random() * 160;
  const light = 35 + Math.floor(Math.random() * 5) * 10;
  blob.style.setProperty('--size', `${size}px`);
  blob.style.setProperty('--light', `${light}%`);
  blob.style.setProperty('--top', `${Math.random() * 90}%`);
  blob.style.setProperty('--left', `${Math.random() * 90}%`);
});
Enter fullscreen mode Exit fullscreen mode

Ten lines of script turned into a few declarations. For a hero section, I will take that trade every time.

Shipping it today: a fallback that only runs when needed

Firefox does not support random() yet. Smashing Magazine reports positive signals from Mozilla, but no release. So for client work, I treat it as progressive enhancement.

Step one: write a calm default first, then add the random layer inside a feature query. MDN uses order: random(1, 2) as the test, which is a nice trick.

.card {
  rotate: var(--tilt, 0deg);
}

@supports (order: random(1, 2)) {
  .card {
    rotate: random(-6deg, 6deg, 3deg);
  }
}
Enter fullscreen mode Exit fullscreen mode

Step two: if the random effect really matters to the design, add a tiny script that only runs in browsers without support.

const supportsRandom = CSS.supports('order', 'random(1, 2)');

if (!supportsRandom) {
  const steps = [-6, -3, 0, 3, 6];
  document.querySelectorAll('.card').forEach((card) => {
    const tilt = steps[Math.floor(Math.random() * steps.length)];
    card.style.setProperty('--tilt', `${tilt}deg`);
  });
}
Enter fullscreen mode Exit fullscreen mode

Chrome and Safari users get pure CSS. Firefox users get the old behavior. Nobody gets a broken page.

If you want one code path for every browser, CSS-Tricks covered a polyfill that resolves random() with JavaScript. I have not used it in production, so test it before you trust it.

Honest caveats

random() is great. It is not a full replacement for Math.random(). Here is where JavaScript still wins, or where you need to be careful.

You cannot re-roll on demand. The value is picked when styles are computed. There is no "shuffle" button. If a user action should create new values, you still need JavaScript.

Hover states may not change. With the default auto key, the browser builds the seed name from the property and position. So width: random(100px, 200px) on .foo and on .foo:hover share a seed, and nothing changes on hover. MDN shows this case. Use a different named key for the hover rule if you want a new value.

You cannot read the value easily. If your WebGL scene or GSAP timeline needs to know the exact random number, keep it in JavaScript. Syncing CSS randomness into a canvas is more work than it saves.

Contrast is your problem now. A random text color can land on an unreadable value. MDN warns about this directly. Randomize decoration, not readable content, or keep the range tight.

Motion still needs an off switch. Random delays and positions are often used in animation. Wrap that animation in prefers-reduced-motion like any other.

Support is still partial. Chrome 156 and Safari support it. Firefox does not, as of today. MDN still marks it as limited availability.

What to do this week

  1. Open Chrome 156 or Safari and paste the card example into a CodePen. It takes two minutes and you will understand the keys faster than by reading.
  2. Search your codebase for Math.random() used only to set styles. Those are your candidates.
  3. Pick one decorative effect and move it to CSS behind @supports (order: random(1, 2)).
  4. Keep a calm default for Firefox. Add the JavaScript fallback only if the effect is part of the design.
  5. Ignore any snippet that uses element-shared. Translate it to element-scoped or a named key.
  6. Leave anything interactive, readable or synced with canvas in JavaScript.

Top comments (0)