CSS + JS Scenes & objects #generated #loop #particles #scene #weather

Depth snowfall in CSS + JavaScript

JS creates forty flakes once, each with a random depth, speed and drift in custom properties. One CSS animation does the falling; perspective makes near flakes big and fast.

  • 72 lines of CSS
  • 9 lines of HTML
  • 18 lines of JS
  • No dependencies
  • Snippet under MIT
GitHub

How it works

  1. JS creates forty flakes once and gives each random custom properties: position --x, depth --z, size, opacity, sideways --drift, duration --t and delay --d. After that it does nothing.
  2. One keyframe animation does all the falling, at each flake's own depth --z: from 53% of the canvas above the top to 185% below it, drifting sideways by --drift. The night is a size container, so those are cqh and cqw, shares of the canvas. Perspective makes near flakes big and fast, far ones small and slow.
  3. The fall path is longer than the canvas because perspective pulls far flakes toward the centre: they need the extra distance to reach the top and bottom edges. Perspective scales everything at one depth by the same amount about the middle, so a flake placed in shares of the canvas stays at the same share on every canvas shape: a tall canvas shows the same snow, not more of it above the trees.
  4. A negative delay between 0 and the duration means every flake starts mid-fall: it is already snowing on the first frame. Trees sit at real depths in the same 3D space, so flakes behind them are hidden.
  5. The night is inset: 0 and laid out in percentages, so it fills the canvas edge to edge whatever its shape. Its lengths are multiples of one base unit, --u, tied to the canvas, and JS writes depth and size as plain numbers that CSS multiplies by it, so a flake is the same size on a gallery card and on a full screen. The drift is a plain number too, which CSS counts in shares of the canvas width.

Key techniques

  • random --x / --z / --t / --d per flake
  • translate3d keyframes with var()
  • negative delays = already snowing
  • trees at real depths hide far flakes

Copy-paste code

HTML 9 lines

<div class="night">
  <div class="moon"></div>
  <div class="hill"></div>
  <div class="world">
    <b style="--x:8%;--z:-240"></b>
    <b style="--x:66%;--z:-140"></b>
    <b style="--x:34%;--z:-20"></b>
  </div>
</div>

CSS 72 lines

.night {
  /* one base unit, tied to the canvas; the scene itself fills the canvas edge to edge, its
     layout in percentages of it and its lengths in units */
  --u: 0.33vmin;
  position: fixed;
  inset: 0;
  overflow: hidden;
  container-type: size; /* inside it, 1cqw and 1cqh are 1% of the canvas across and down */
  perspective: calc(400 * var(--u));
  background: linear-gradient(#050817, #1f1d5a 75%, #1a2e4a);
}

.moon {
  position: absolute;
  top: 12%;
  right: 16%;
  width: calc(38 * var(--u));
  height: calc(38 * var(--u));
  border-radius: 50%;
  background: radial-gradient(circle at 60% 40%, #fffbe8, #e6e2ff 70%);
  box-shadow: 0 0 calc(24 * var(--u)) rgb(255 250 230 / 0.55), 0 0 calc(70 * var(--u)) rgb(139 108 255 / 0.45);
}

.hill {
  position: absolute;
  left: -20%;
  right: -20%;
  bottom: -32%;
  height: 62%;
  border-radius: 50% 50% 0 0 / 34% 34% 0 0;
  background: linear-gradient(#eef1ff, #b9c0e8 40%, #8d95c8);
}

.world {
  position: absolute;
  inset: 0;
  transform-style: preserve-3d;
  pointer-events: none;
}

/* pine trees at real depths; --z, --s and --drift are plain numbers, counted in units */
.world b {
  position: absolute;
  bottom: 3%;
  left: var(--x);
  width: calc(70 * var(--u));
  height: calc(104 * var(--u));
  background:
    linear-gradient(transparent 26%, #e9ecff 26% 30%, transparent 30% 55%, #e9ecff 55% 59%, transparent 59%),
    linear-gradient(#135a55, #07161c);
  clip-path: polygon(50% 0, 72% 28%, 62% 28%, 84% 57%, 70% 57%, 96% 88%, 56% 88%, 56% 100%, 44% 100%, 44% 88%, 4% 88%, 30% 57%, 16% 57%, 38% 28%, 28% 28%);
  transform: translateZ(calc(var(--z) * var(--u)));
}

.world i {
  position: absolute;
  top: 0;
  left: var(--x);
  width: calc(var(--s) * var(--u));
  height: calc(var(--s) * var(--u));
  border-radius: 50%;
  background: radial-gradient(circle, #fff 0 35%, rgb(255 255 255 / 0) 70%);
  opacity: var(--o);
  animation: fall var(--t) linear var(--d) infinite;
}

/* the fall and the drift are shares of the canvas (cqh, cqw), so on any canvas shape a flake is at
   the same share of it at the same moment; only its depth and size are in units */
@keyframes fall {
  from { transform: translate3d(0, -53cqh, calc(var(--z) * var(--u))); }
  to   { transform: translate3d(calc(var(--drift) * 0.33cqw), 185cqh, calc(var(--z) * var(--u))); }
}

JS 18 lines

const world = document.querySelector('.world');
const rand = (min, max) => min + Math.random() * (max - min);

for (let n = 0; n < 40; n++) {
  const flake = document.createElement('i');
  const z = rand(-300, 150);
  const near = (z + 300) / 450;                  // 0 = far … 1 = near
  const t = (9 - near * 5) * rand(0.85, 1.15);   // near flakes fall faster
  flake.style.setProperty('--x', rand(-25, 125).toFixed(1) + '%');
  // lengths go in as plain numbers: the CSS counts depth and size in --u, and the drift in shares of the canvas
  flake.style.setProperty('--z', z.toFixed(0));
  flake.style.setProperty('--s', rand(4, 8).toFixed(1));
  flake.style.setProperty('--o', (0.45 + near * 0.5).toFixed(2));
  flake.style.setProperty('--drift', rand(-40, 40).toFixed(0));
  flake.style.setProperty('--t', t.toFixed(2) + 's');
  flake.style.setProperty('--d', -rand(0, t).toFixed(2) + 's'); // already falling
  world.append(flake);
}
Standalone snippet · plain CSS, no build step, no dependencies.