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.

  • 66 lines of CSS
  • 9 lines of HTML
  • 17 lines of JS
  • No dependencies
  • MIT licensed
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: translate3d(0, -160px, var(--z))translate3d(var(--drift), 560px, var(--z)). Perspective makes near flakes big and fast, far ones small and slow.
  3. The fall path is longer than the screen because perspective pulls far flakes toward the centre: they need the extra distance to reach the top and bottom edges.
  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.

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:-240px"></b>
    <b style="--x:66%;--z:-140px"></b>
    <b style="--x:34%;--z:-20px"></b>
  </div>
</div>

CSS 66 lines

.night {
  position: fixed;
  inset: 0;
  overflow: hidden;
  perspective: 400px;
  background: linear-gradient(#050817, #1f1d5a 75%, #1a2e4a);
}

.moon {
  position: absolute;
  top: 12%;
  right: 16%;
  width: 38px;
  height: 38px;
  border-radius: 50%;
  background: radial-gradient(circle at 60% 40%, #fffbe8, #e6e2ff 70%);
  box-shadow: 0 0 24px rgb(255 250 230 / 0.55), 0 0 70px 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 */
.world b {
  position: absolute;
  bottom: 3%;
  left: var(--x);
  width: 70px;
  height: 104px;
  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(var(--z));
}

.world i {
  position: absolute;
  top: 0;
  left: var(--x);
  width: var(--s);
  height: var(--s);
  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;
}

@keyframes fall {
  from { transform: translate3d(0, -160px, var(--z)); }
  to   { transform: translate3d(var(--drift), 560px, var(--z)); }
}

JS 17 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) + '%');
  flake.style.setProperty('--z', z.toFixed(0) + 'px');
  flake.style.setProperty('--s', rand(4, 8).toFixed(1) + 'px');
  flake.style.setProperty('--o', (0.45 + near * 0.5).toFixed(2));
  flake.style.setProperty('--drift', rand(-40, 40).toFixed(0) + 'px');
  flake.style.setProperty('--t', t.toFixed(2) + 's');
  flake.style.setProperty('--d', -rand(0, t).toFixed(2) + 's'); // already falling
  world.append(flake);
}

Sass source 97 lines

// JS writes each flake's values once (--x, --z, --s, --o, --drift, --t, --d); this one
// animation does all the falling. The fall is long in px because far flakes are scaled
// toward the centre by perspective and need a longer path to cross the whole frame.
$persp: 400px;

.d-snow {
  position: absolute;
  inset: 0;
  overflow: hidden;
  perspective: $persp;
  background: linear-gradient(#050817, color-mix(in srgb, var(--accent) 32%, #0b1030) 75%, color-mix(in srgb, var(--accent-2) 16%, #121a3a));

  &__moon {
    position: absolute;
    top: 12%;
    right: 16%;
    width: 38px;
    height: 38px;
    border-radius: 50%;
    background: radial-gradient(circle at 60% 40%, #fffbe8, #e6e2ff 70%);
    box-shadow:
      0 0 24px rgb(255 250 230 / 0.55),
      0 0 70px color-mix(in srgb, var(--accent) 45%, transparent);
  }

  // a snowy slope, flat, behind the 3D world
  &__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);
    box-shadow: 0 -6px 30px color-mix(in srgb, #dfe4ff 25%, transparent);
  }

  &__world {
    position: absolute;
    inset: 0;
    transform-style: preserve-3d;
    pointer-events: none;

    // Pine trees at real depths: flakes further away pass behind them.
    b {
      position: absolute;
      bottom: 3%;
      left: var(--x);
      width: 70px;
      height: 104px;
      background:
        // snow on the tiers
        linear-gradient(transparent 26%, #e9ecff 26% 30%, transparent 30% 55%, #e9ecff 55% 59%, transparent 59%),
        linear-gradient(color-mix(in srgb, var(--accent-2) 30%, #0e2a2e), #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(var(--z));
    }

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

@keyframes d-snow-fall {
  from {
    transform: translate3d(0, -160px, var(--z));
  }

  to {
    transform: translate3d(var(--drift), 560px, var(--z));
  }
}
Standalone snippet · plain CSS, no build step, no dependencies.