Most demos need a newer browser

They use :has() and container queries. Update your browser, or on an iPhone update iOS to 16.4 or later.

CSS Falling Snow

Pure CSS snow in three depth layers: each flake falls on one @keyframes rule and sways on another, varied by nth-child cycles.

Level
Intermediate
Published
Updated
Reading time
8 min

Try it

3 live demos

Thirty flakes in three layers

Other ways to build it

Build it

6 snippets to copy

CSS Falling Snow

HTML

<div class="snow-stage" aria-hidden="true">
  <span class="flake"></span>
  <span class="flake"></span>
  <span class="flake"></span>
  <span class="flake"></span>
  <span class="flake"></span>
  <span class="flake"></span>
  <span class="flake"></span>
  <span class="flake"></span>
  <span class="flake"></span>
  <span class="flake"></span>
  <span class="flake"></span>
  <span class="flake"></span>
  <span class="flake"></span>
  <span class="flake"></span>
  <span class="flake"></span>
  <span class="flake"></span>
  <span class="flake"></span>
  <span class="flake"></span>
  <span class="flake"></span>
  <span class="flake"></span>
  <span class="flake"></span>
  <span class="flake"></span>
  <span class="flake"></span>
  <span class="flake"></span>
  <span class="flake"></span>
  <span class="flake"></span>
  <span class="flake"></span>
  <span class="flake"></span>
  <span class="flake"></span>
  <span class="flake"></span>
</div>

CSS

/* no background: the snow sits on whatever is behind the stage */
.snow-stage {
  position: relative;
  width: 100%;
  height: 260px;
  overflow: hidden;
}

/* each flake is a column as tall as the stage; the dot rides on top of it */
.flake {
  position: absolute;
  top: 0;
  bottom: 0;
  width: var(--size);
  animation: fs-fall linear infinite;
  animation-duration: calc(var(--fall) * var(--speed) * var(--pace, 1));
  animation-delay: calc(var(--fall) * var(--speed) * var(--pace, 1) * var(--start) * -1);
}

/* the visible flake, which sways on its own clock */
.flake::before {
  content: "";
  position: absolute;
  bottom: 100%;
  left: 0;
  width: var(--size);
  height: var(--size);
  border-radius: 50%;
  background: #ffffff;
  opacity: var(--glow, 1);
  box-shadow: 0 0 4px 1px rgba(96, 132, 184, .8), 0 1px 2px rgba(40, 70, 120, .35);
  animation: fs-sway ease-in-out infinite alternate;
  animation-duration: var(--sway);
  animation-delay: calc(var(--sway) * var(--start) * -2);
}

/* depth: every third flake is far, middle or near */
.flake:nth-child(3n+1) { --size: 3px; --glow: .6;  --fall: 15s;   --sway: 4.2s; }
.flake:nth-child(3n+2) { --size: 5px; --glow: .85; --fall: 10.5s; --sway: 3.4s; }
.flake:nth-child(3n)   { --size: 8px; --glow: 1;   --fall: 7.5s;  --sway: 2.7s; }

/* ten columns across */
.flake:nth-child(10n+1) { left: 3%; }
.flake:nth-child(10n+2) { left: 13%; }
.flake:nth-child(10n+3) { left: 22%; }
.flake:nth-child(10n+4) { left: 32%; }
.flake:nth-child(10n+5) { left: 41%; }
.flake:nth-child(10n+6) { left: 51%; }
.flake:nth-child(10n+7) { left: 60%; }
.flake:nth-child(10n+8) { left: 70%; }
.flake:nth-child(10n+9) { left: 79%; }
.flake:nth-child(10n)   { left: 89%; }

/* a speed and a small nudge, every fourth flake */
.flake:nth-child(4n+1) { --speed: 1; }
.flake:nth-child(4n+2) { --speed: 1.17; margin-left: 3%; }
.flake:nth-child(4n+3) { --speed: .88;  margin-left: -2%; }
.flake:nth-child(4n)   { --speed: 1.08; margin-left: 5%; }

/* where in its fall each flake starts, every seventh */
.flake:nth-child(7n+1) { --start: .05; }
.flake:nth-child(7n+2) { --start: .62; }
.flake:nth-child(7n+3) { --start: .31; }
.flake:nth-child(7n+4) { --start: .87; }
.flake:nth-child(7n+5) { --start: .44; }
.flake:nth-child(7n+6) { --start: .18; }
.flake:nth-child(7n)   { --start: .73; }

/* fading at both ends hides the clip line when the stage has no edge */
@keyframes fs-fall {
  from { transform: translateY(0); opacity: 0; }
  10%  { opacity: 1; }
  88%  { opacity: 1; }
  to   { transform: translateY(calc(100% + 12px)); opacity: 0; }
}

@keyframes fs-sway {
  from { transform: translateX(-160%); }
  to   { transform: translateX(160%); }
}

/* paused, not removed, so the snow stays on the stage */
@media (prefers-reduced-motion: reduce) {
  .flake,
  .flake::before { animation-play-state: paused; }
}

A snow globe

The same flakes and cycles inside a round glass with border-radius: 50% and overflow: hidden, which clips the snow to the circle. The glass is a 200px circle with a night blue radial-gradient and an inset box-shadow for the rim, and .globe-glass::after is the highlight rotated over the upper left. One custom property speeds the whole set up for the smaller space: .globe-glass .flake { --pace: .55; } runs them at 55% of the main demo's times, so the snow looks like it was recently shaken. The fall still fits without any new numbers because it is a percentage of the column's height. The tree and the base are green and brown gradients cut to shape with clip-path: polygon().

HTML

<div class="snow-globe" aria-hidden="true">
  <div class="globe-glass">
    <span class="flake"></span>
    <span class="flake"></span>
    <span class="flake"></span>
    <!-- ...eighteen flakes in all -->
    <div class="globe-ground"></div>
    <div class="globe-tree"></div>
  </div>
  <div class="globe-base"></div>
</div>

CSS

/* uses .flake, its cycles and @keyframes fs-fall and fs-sway from the main
   example above; these rules are the globe around them */
.snow-globe {
  display: flex;
  flex-direction: column;
  align-items: center;
}

/* the round glass clips the snow to the globe */
.globe-glass {
  position: relative;
  width: 200px;
  height: 200px;
  overflow: hidden;
  border-radius: 50%;
  background: radial-gradient(circle at 50% 35%, #2c4a7a 0%, #172a4d 60%, #0e1a33 100%);
  box-shadow: inset 0 0 0 2px rgba(255, 255, 255, .22), inset -14px -18px 36px rgba(4, 10, 24, .55);
}

/* the highlight on the glass */
.globe-glass::after {
  content: "";
  position: absolute;
  top: 14%;
  left: 18%;
  width: 34%;
  height: 22%;
  border-radius: 50%;
  background: radial-gradient(ellipse at 40% 40%, rgba(255, 255, 255, .38), rgba(255, 255, 255, 0) 70%);
  transform: rotate(-28deg);
}

/* a shorter fall, so the snow speeds up to match */
.globe-glass .flake {
  --pace: .55;
}

.globe-ground {
  position: absolute;
  left: -10%;
  right: -10%;
  bottom: -38%;
  height: 60%;
  border-radius: 50%;
  background: radial-gradient(ellipse at 50% 20%, #ffffff, #d5e2f2 70%);
}

.globe-tree {
  position: absolute;
  left: 50%;
  bottom: 24%;
  width: 64px;
  height: 86px;
  margin-left: -32px;
  background: linear-gradient(#2f8f5b, #1f6b44);
  clip-path: polygon(50% 0, 70% 30%, 62% 30%, 84% 62%, 74% 62%, 100% 100%, 0 100%, 26% 62%, 16% 62%, 38% 30%, 30% 30%);
}

.globe-base {
  width: 150px;
  height: 40px;
  margin-top: -14px;
  border-radius: 6px 6px 14px 14px;
  background: linear-gradient(#7a4a2c, #4f2e1a);
  clip-path: polygon(8% 0, 92% 0, 100% 100%, 0 100%);
}

A storm over a snowbank

The same flakes on a slanted path at about a third of the time. .snow-storm .flake { --pace: .32; animation-name: fs-gust; } swaps the fall for @keyframes fs-gust, which goes from translate(-70px, 0) to translate(70px, calc(100% + 12px)). Each flake starts 70px left of its column and ends 70px right of it, so the wind carries snow in from the left edge as well as out at the right. The sway still runs on top of it. The settled snow is .snow-bank, three overlapping radial-gradient ellipses along the bottom 64px, drawn in front of the flakes so the falling snow disappears into it. The bank is static on purpose: a drift that grows and then resets would pop visibly on every loop. The storm has its own slate blue sky so the white bank shows on any page.

HTML

<div class="snow-storm" aria-hidden="true">
  <span class="flake"></span>
  <span class="flake"></span>
  <span class="flake"></span>
  <!-- ...forty flakes in all -->
  <div class="snow-bank"></div>
</div>

CSS

/* uses .flake and its cycles from the main example above; the storm swaps
   the fall for a slanted one and runs it at about a third of the time */
.snow-storm {
  position: relative;
  width: 100%;
  height: 260px;
  overflow: hidden;
  border-radius: 12px;
  background: linear-gradient(180deg, #3b4a63 0%, #5d6f8c 70%, #8193ae 100%);
}

.snow-storm .flake {
  --pace: .32;
  animation-name: fs-gust;
}

/* the settled snow, in front of the flakes so they vanish into it */
.snow-bank {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  height: 64px;
  background:
    radial-gradient(ellipse 40% 70% at 18% 100%, #ffffff 98%, transparent 100%),
    radial-gradient(ellipse 45% 60% at 62% 100%, #f3f7fc 98%, transparent 100%),
    radial-gradient(ellipse 35% 80% at 96% 100%, #e6eef8 98%, transparent 100%);
}

/* starts left of the column and ends right of it, so the wind carries
   snow in from the left edge as well as out at the right */
@keyframes fs-gust {
  from { transform: translate(-70px, 0); }
  to   { transform: translate(70px, calc(100% + 12px)); }
}

Understand it

How it works and 10 properties

Pure CSS falling snow is thirty flakes in three depth layers, no images and no JavaScript. Each flake is a single element that falls while its ::before sways, so two motions run at once without fighting over one transform. The sway is a percentage of the flake's own width, which gives nearer flakes a wider drift and makes the depth read without any extra rule.

Two more builds follow: a snow globe where the same flakes and cycles snow inside a round glass with overflow: hidden, and a storm over a snowbank where the flakes fall at about a third of the time on a slanted path and disappear into a static drift.

How it works

The stage is a 260px box with overflow: hidden and no background. Each flake is a column as tall as the stage, absolutely positioned with top: 0; bottom: 0. The visible flake is its ::before, a white disc placed above the column's top at bottom: 100%. The flake element animates translateY(calc(100% + 12px)), moving by its own height so the fall fits any stage height. The ::before sways with its own translateX animation, ease-in-out alternate, so two motions never write to one transform. Depth layers come from :nth-child(3n) cycles: far flakes are 3px, dim and slow, near ones 8px, bright and fast.

Each flake is a column as tall as the stage for a reason: translateY(calc(100% + 12px)) moves it by its own height, so a percentage in translateY is a percentage of the element itself. The column is the stage's height, which means the flake always travels from above the stage to 12px below it, whatever height the stage has. The fall is a percentage of the flake, not a fixed pixel distance, so it fits any stage height. The +12px clears the biggest flake, and the loop wraps while clipped.

Two motions on one flake work because they split the transform between two elements. The flake element owns the fall, translateY with linear timing. Its ::before owns the sway, translateX with ease-in-out alternate, so it drifts left and right on its own clock while the parent falls. Each has its own duration, so they never line up. Putting both animations on one element would have the second replace the first, and the flake would either fall or sway but not both.

Four :nth-child cycles of different lengths set only custom properties, never the animation shorthand, so no cycle resets what another set. The 3n cycle sets layer (--size, --glow, --fall, --sway), the 10n cycle sets column (left), the 4n cycle sets --speed and a small margin-left nudge, and the 7n cycle sets --start, where in its fall each flake begins. 3 and 10 repeat together every 30 flakes, so all thirty have their own layer and column pair. The 4 and 7 cycles cut across those, so neighbors differ in speed and start too. The animation rules read them with calc(var(--fall) * var(--speed) * var(--pace, 1)).

Depth layers come from size, opacity and timing. Far flakes are 3px wide, 60% opaque and fall in 15s. Middle flakes are 5px, 85% opaque and fall in 10.5s. Near flakes are 8px, full opacity and fall in 7.5s. Nearer things move faster and travel farther in each direction, which is why the sway is a percentage of the flake's own width. A 3px far flake swings about 4.8px each way at translateX(-160%) to translateX(160%), and an 8px near flake swings about 12.8px, so the depth reads without any extra rule.

Negative delays fill the stage on the first frame. Each flake's animation-delay is calc(var(--fall) * var(--speed) * var(--pace, 1) * var(--start) * -1), so a flake with --start: .62 begins 62% of the way through its fall and is already partway down when the page loads. Under prefers-reduced-motion: reduce the flakes pause in place with animation-play-state: paused. A paused animation still respects its delay, so each flake stays frozen where the delay put it and the stage shows a still snowfall. animation: none would instead snap every flake back above the stage and leave an empty box.

Accessibility notes

The stage is pure decoration and carries aria-hidden="true", so the empty spans are removed from the accessibility tree. Keep anything interactive or readable outside the hidden subtree.

Under prefers-reduced-motion: reduce every flake pauses in place and the stage still shows snow. Slow drift is gentler than rain, but a full-width loop is still motion the reader did not start, which is why it stops for reduced motion.

The flakes are bright moving dots. Text placed on the stage would change contrast as flakes cross it, so keep body text off the stage or above it with position: relative; z-index: 1.

Mistakes worth avoiding

  • Putting the fall and the sway in one transform on one element. Two animations writing transform on the same element do not add up. The later one wins. Splitting them between the element and its ::before gives each its own transform.
  • Using the animation shorthand inside an nth-child cycle. The shorthand resets every longhand, so a cycle that sets animation would erase the duration and delay another cycle set. The cycles here set custom properties only.
  • A fixed pixel fall distance on a stage that can grow. translateY(272px) for a 260px stage would leave the flakes hanging partway down a taller field. Moving the flake by 100% of itself fits any height.
  • animation: none under reduced motion. It snaps every flake back to its starting point above the stage and leaves an empty box. animation-play-state: paused keeps them where the negative delays put them.
  • Easing the fall. A timing function applies per keyframe segment, so an eased animation with opacity stops at 10% and 88% would slow down twice per flake. The fall is linear and the sway is ease-in-out alternate, where the easing is the point.

Ship it

9 features in 4 browsers, 5 questions

Browser support

Feature Chrome Firefox Safari Edge
animation455.112
transform43.53.112
custom properties49311016
calc()194612
opacity423.112
:nth-child()43.53.212
::before423.112
clip-path: polygon()555413.179
prefers-reduced-motion746310.179

Everything here is long supported. The main demo needs custom properties inside calc() for animation timing, which is Chrome 49, Firefox 31, Safari 10 and Edge 16. The snow globe's tree and base use clip-path: polygon() (Chrome 55, Firefox 54, Safari 13.1, Edge 79), and without it they show as plain rectangles while the snow is unaffected. Figures come from Can I Use.

What you can build with it

  • Seasonal hero or header. The stage at full width over a heading image or color, with pointer-events: none so it never blocks a click.
  • Holiday sale or promo card. A card that snows inside its own bounds. The globe shows the clipping with overflow: hidden and border-radius: 50%.
  • Weather widget. A snow forecast with the effect matching the condition. Use the storm variant for heavy snow.
  • Winter empty state or 404 page. A seasonal empty state, 404 page or closed for the holidays notice. It uses thirty elements, no images, and animates only transforms and opacity.

Frequently asked questions

How do you make falling snow with only CSS?
Absolutely positioned spans in a stage with overflow: hidden, each a column the stage's height with its visible flake at ::before. The column animates translateY(calc(100% + 12px)) and the ::before animates translateX, splitting the motions between two elements so each has its own transform. Vary size, timing and starting point with :nth-child cycles.
How do I make the snow sway as it falls?
Put the sway on the ::before with its own translateX animation, ease-in-out alternate, so it does not replace the fall's transform. The element owns the fall, the ::before owns the sway, each with its own duration.
How do I add snow over my whole page?
position: fixed; inset: 0; pointer-events: none on the stage, and a z-index above the content. The fall is a percentage of the stage's height, so it fits the viewport with no new numbers.
How many snowflakes can I animate?
Thirty here, forty in the storm. Each is one element and the animations are transform and opacity only, which the browser can run on the compositor.
Does it stop for people who turn off animations?
Yes. Under prefers-reduced-motion: reduce the flakes pause in place with animation-play-state: paused, and the stage shows a still snowfall.