CSS Rain Effect

Animated rain streaks using absolutely-positioned elements and staggered @keyframes.

Published July 1, 2026 Intermediate 8 min read

A CSS rain effect is twenty thin elements falling down a clipped box, all driven by one @keyframes rule. Each drop is a single pixel wide with a gradient running from transparent at the top to a pale blue at the bottom, which is what gives it a head and a tail instead of the flat look of a drawn line. No canvas, no images, no script.

The stage below runs at rain speed, between 0.8 and 1.4 seconds per drop. Two more builds follow: wind driven rain that falls at an angle, and a version where each drop triggers a splash ring the moment it reaches the floor, synchronised with nothing more than a shared duration.

Twenty drops, one keyframe

☔ CSS Rain

HTML

<div class="rain-stage">
  <div class="drop"></div>
  <div class="drop"></div>
  <div class="drop"></div>
  <div class="drop"></div>
  <div class="drop"></div>
  <div class="drop"></div>
  <div class="drop"></div>
  <div class="drop"></div>
  <div class="drop"></div>
  <div class="drop"></div>
  <div class="drop"></div>
  <div class="drop"></div>
  <div class="drop"></div>
  <div class="drop"></div>
  <div class="drop"></div>
  <div class="drop"></div>
  <div class="drop"></div>
  <div class="drop"></div>
  <div class="drop"></div>
  <div class="drop"></div>
  <p class="rain-text">☔ CSS Rain</p>
</div>

CSS

/* overflow: hidden clips the drops once they pass the bottom edge */
.rain-stage {
  position: relative;
  width: 100%;
  height: 220px;
  overflow: hidden;
  border-radius: 12px;
  background: linear-gradient(180deg, #0a0e14 0%, #111c2e 100%);
}

.rain-text {
  position: absolute;
  bottom: 1.5rem;
  left: 50%;
  transform: translateX(-50%);
  z-index: 1;
  color: rgba(255, 255, 255, .7);
  font-size: 1.1rem;
  font-weight: 600;
  white-space: nowrap;
}

.drop {
  position: absolute;
  top: -20px;
  width: 1px;
  border-radius: 0 0 1px 1px;
  background: linear-gradient(180deg, transparent, rgba(147, 197, 253, .8));
  animation: rain-fall linear infinite;
}

/* Column, length and timing vary per drop so the rain looks random */
.drop:nth-child(1)  { left: 5%;  height: 18px; animation-duration: 1.1s;  animation-delay: 0s; }
.drop:nth-child(2)  { left: 9%;  height: 14px; animation-duration: 0.9s;  animation-delay: 0.3s; }
.drop:nth-child(3)  { left: 14%; height: 22px; animation-duration: 1.3s;  animation-delay: 0.7s; }
.drop:nth-child(4)  { left: 19%; height: 16px; animation-duration: 1.0s;  animation-delay: 0.1s; }
.drop:nth-child(5)  { left: 24%; height: 20px; animation-duration: 1.2s;  animation-delay: 0.5s; }
.drop:nth-child(6)  { left: 29%; height: 14px; animation-duration: 0.8s;  animation-delay: 0.9s; }
.drop:nth-child(7)  { left: 34%; height: 24px; animation-duration: 1.4s;  animation-delay: 0.2s; }
.drop:nth-child(8)  { left: 38%; height: 16px; animation-duration: 1.0s;  animation-delay: 0.6s; }
.drop:nth-child(9)  { left: 43%; height: 18px; animation-duration: 1.1s;  animation-delay: 0.4s; }
.drop:nth-child(10) { left: 48%; height: 12px; animation-duration: 0.9s;  animation-delay: 0.8s; }
.drop:nth-child(11) { left: 53%; height: 20px; animation-duration: 1.2s;  animation-delay: 0.15s; }
.drop:nth-child(12) { left: 58%; height: 16px; animation-duration: 1.0s;  animation-delay: 0.55s; }
.drop:nth-child(13) { left: 63%; height: 22px; animation-duration: 1.3s;  animation-delay: 0.35s; }
.drop:nth-child(14) { left: 68%; height: 14px; animation-duration: 0.8s;  animation-delay: 0.75s; }
.drop:nth-child(15) { left: 73%; height: 18px; animation-duration: 1.1s;  animation-delay: 0.45s; }
.drop:nth-child(16) { left: 78%; height: 20px; animation-duration: 1.2s;  animation-delay: 0.05s; }
.drop:nth-child(17) { left: 83%; height: 12px; animation-duration: 0.9s;  animation-delay: 0.65s; }
.drop:nth-child(18) { left: 88%; height: 24px; animation-duration: 1.4s;  animation-delay: 0.25s; }
.drop:nth-child(19) { left: 93%; height: 16px; animation-duration: 1.0s;  animation-delay: 0.85s; }
.drop:nth-child(20) { left: 97%; height: 18px; animation-duration: 1.15s; animation-delay: 0.5s; }

@keyframes rain-fall {
  0% {
    transform: translateY(0) scaleX(1);
    opacity: 0;
  }
  5%  { opacity: 1; }
  95% { opacity: .8; }
  100% {
    transform: translateY(240px) scaleX(0.5);
    opacity: 0;
  }
}

Other ways to build it

Wind driven rain at an angle

Two things have to change together for slanted rain to look right. The path moves on both axes, 70px across for every 240px down, and the drop itself is rotated by the angle that path makes with vertical, which is about 16 degrees. Rotate without moving sideways and the drops slide down while pointing somewhere else, which reads as a mistake immediately. The rotation lives inside every keyframe rather than in a separate rule, because transform is one property and a later declaration replaces the whole thing rather than adding to it.

HTML

<div class="rain-wind-stage">
  <div class="rw-drop"></div>
  <div class="rw-drop"></div>
  <div class="rw-drop"></div>
  <div class="rw-drop"></div>
  <div class="rw-drop"></div>
  <div class="rw-drop"></div>
  <div class="rw-drop"></div>
  <div class="rw-drop"></div>
  <div class="rw-drop"></div>
  <div class="rw-drop"></div>
  <div class="rw-drop"></div>
  <div class="rw-drop"></div>
  <div class="rw-drop"></div>
  <div class="rw-drop"></div>
</div>

CSS

.rw-drop {
  position: absolute;
  top: -26px;
  width: 1px;
  background: linear-gradient(180deg, transparent, rgba(147, 197, 253, .85));
  animation: rain-gust linear infinite;
}

.rw-drop:nth-child(1) { left: 2%;  height: 20px; animation-duration: 1.1s; animation-delay: 0s; }
.rw-drop:nth-child(2) { left: 9%;  height: 15px; animation-duration: 0.9s; animation-delay: .3s; }
.rw-drop:nth-child(3) { left: 16%; height: 24px; animation-duration: 1.3s; animation-delay: .6s; }

/* the rotation is repeated in every keyframe: transform is a single
   property, so a later declaration replaces it rather than adding to it */
@keyframes rain-gust {
  0% {
    transform: translate(0, 0) rotate(-16deg);
    opacity: 0;
  }
  6%  { opacity: 1; }
  94% { opacity: .8; }
  100% {
    transform: translate(70px, 240px) rotate(-16deg);
    opacity: 0;
  }
}

@media (prefers-reduced-motion: reduce) {
  .rw-drop { animation: none; opacity: .35; }
}

Splash rings, synchronised by shared timing

CSS has no way for one animation to notify another, so a drop cannot tell its splash that it has landed. What it can do is share a clock. Each column here is a class carrying both a left position and a duration and delay pair, and both the drop and the ring under it pick that class up. The ring's keyframes then stay invisible for the first 86% of the cycle, which is the point at which a drop traveling 240px reaches the floor line 200px down, and only then expand and fade. Move the floor and that percentage has to move with it, which is the price of synchronising two animations by arithmetic instead of by event.

HTML

<div class="rain-splash-stage">
  <div class="rs-drop rs-1"></div>
  <div class="rs-drop rs-2"></div>
  <div class="rs-ring rs-1"></div>
  <div class="rs-ring rs-2"></div>
  <div class="rain-floor"></div>
</div>

CSS

/* uses @keyframes rain-fall from the main example above; the rules here are
   what changes, not the whole file */
/* one class per column carries the position AND the clock, so the drop
   and its ring are guaranteed to share a duration and a delay */
.rs-1 { left: 12%; animation-duration: 1.1s;  animation-delay: 0s; }
.rs-2 { left: 27%; animation-duration: 1.35s; animation-delay: .35s; }

.rs-drop {
  position: absolute;
  top: -20px;
  width: 1px;
  height: 16px;
  background: linear-gradient(180deg, transparent, rgba(147, 197, 253, .85));
  animation-name: rain-fall;
  animation-timing-function: linear;
  animation-iteration-count: infinite;
}

.rs-ring {
  position: absolute;
  top: 192px;
  width: 24px;
  height: 11px;
  margin-left: -12px;
  border: 1.5px solid rgba(147, 197, 253, .85);
  border-radius: 50%;
  opacity: 0;
  animation-name: rain-splash;
  animation-timing-function: linear;
  animation-iteration-count: infinite;
}

.rain-floor {
  position: absolute;
  left: 0;
  right: 0;
  top: 200px;
  height: 1px;
  background: rgba(147, 197, 253, .22);
}

/* silent until 85%, which is where a drop falling 240px crosses the
   floor line 200px down. Move the floor and this number moves too. */
@keyframes rain-splash {
  0%, 85% { transform: scale(.25); opacity: 0; }
  87%     { transform: scale(.35); opacity: .9; }
  100%    { transform: scale(1); opacity: 0; }
}

@media (prefers-reduced-motion: reduce) {
  .rs-drop,
  .rs-ring { animation: none; }
  .rs-ring { opacity: 0; }
}

How it works

Rain drops are thin <div> elements carrying a vertical gradient from transparent to a semi transparent blue white. Each drop starts above the container at top: -20px and animates to the bottom with translateY. Different animation-duration values between 0.8s and 1.4s, plus staggered animation-delay values, keep the drops from falling in step. The container uses overflow: hidden to clip each drop once it leaves.

The gradient is what makes a drop look like water. A solid 1px line reads as a scratch on the screen. linear-gradient(180deg, transparent, rgba(147, 197, 253, .8)) fades the top of the element out completely and leaves the bottom near opaque, so the eye reads the bright end as the head of the drop and the faded end as the trail it left behind. Drop height varies between 12px and 24px across the twenty selectors, and that height is the visual length of the streak.

Each drop starts at top: -20px, which is above the stage and therefore clipped, then travels 240px down a 220px box. Both the head start and the overshoot are deliberate: they mean no drop is ever visible appearing or disappearing at the boundary, since it is already out of frame when the cycle wraps. The transform also carries scaleX(0.5). A one pixel element scaled to half a pixel does not vanish, it renders at reduced coverage, so the drop thins out as it falls rather than blinking off.

The timing function is linear, and it has to be. A timing function in CSS applies to every segment between keyframes, not to the animation as a whole, so an eased animation with stops at 5% and 95% restarts its curve at each one and visibly stalls twice per fall. Where the acceleration matters, the fix is to keep the timing linear and to bake the acceleration into the distances between keyframes instead, which is what a falling object does anyway. Rain reaches terminal velocity almost instantly, so linear timing with an even distance is also the physically correct answer.

Twenty drops split across twenty nth-child selectors, each setting a column, a length, a duration and a delay. Durations range from 0.8s to 1.4s, a narrower spread than the one used on the particle float, because real rain falls at similar speeds and too much variation reads as debris rather than weather. Since the durations are not multiples of one another the composite pattern takes a very long time to repeat, which is what stops the reader spotting a loop.

The label sits at z-index: 1 over the drops, and the drops themselves are unpositioned in the stacking order, so they paint behind it. This matters more than it sounds: the drops are semi transparent, and without the explicit stacking they would pass across the text and lower its contrast for a frame at a time. Keeping any real content above the weather is the general rule for an effect like this.

CSS properties used

position
absolute on every drop and relative on the stage. The drops leave the flow entirely, so the stage needs a height set by hand.
transform
translateY for the fall and scaleX to thin the drop out as it goes. Both run on the compositor with no layout or paint cost.
opacity
Ramped up at 5% and back to zero at 100% so the drop is invisible at the ends of the cycle, which hides the jump back to the top.
linear-gradient
Transparent at the top, near opaque at the bottom. This is what separates a drop of water from a one pixel line.
animation-duration
Between 0.8s and 1.4s per drop. Values that are not multiples of one another keep the field from settling into a visible pattern.
animation-delay
Offsets each drop's start so the first frame is not twenty drops in a row along the top edge.
overflow
hidden on the stage clips a drop before it enters and after it leaves, which is why the head start and overshoot are invisible.
z-index
Puts the label above the drops. Without it the semi transparent streaks cross the text and knock its contrast down as they pass.

Browser support

FeatureChromeFirefoxSafariEdge
animation455.112
transform43.53.112
opacity423.112
:nth-child()43.53.212
prefers-reduced-motion746310.179

Figures come from Can I Use. Nothing here is recent, so no prefix or fallback is needed. The one rendering difference worth knowing about is scaleX on a one pixel element: browsers antialias the result rather than snapping it to a whole pixel, and how faint the half pixel drop looks varies a little between engines and device pixel ratios. It never disappears, which is all the effect depends on.

Accessibility notes

Rain is decoration. Mark the stage aria-hidden="true" so a screen reader does not step through twenty empty elements, and keep anything interactive out of it. If the label matters, and in the demo it does, leave the label outside the hidden subtree or move it out of the stage entirely.

Fast repeating motion across a wide area is a stronger trigger than slow drift, and rain is both fast and dense. Anything running at three or more flashes per second across a large region also risks a photosensitive response, which is why the drops here are low contrast and thin rather than bright and wide. Put the animation behind prefers-reduced-motion and let the stage fall back to a still gradient. Both variants below do that.

Do not put body text behind the drops. The streaks are translucent, so text under them changes contrast several times a second, and a ratio that passes when measured against the background can still be unreadable while a drop is crossing a letter. Keep text above the weather with z-index, or keep it out of the box.

What you can build with it

  • Weather widgets. A live condition panel where the animation matches the forecast, which is cheaper than shipping a video loop for every condition.
  • Game and story headers. A mood setting banner over a scene, where the effect is the point rather than an accent.
  • Error and offline states. A rainy empty state reads as a temporary setback rather than a failure, and it costs a few hundred bytes.
  • Seasonal decoration. The same markup with white round drops and a longer duration becomes snow, which is the variant on the particle float page.
  • Video and hero overlays. Drops layered over a still photograph, giving a static image the suggestion of movement without loading a video.

Mistakes worth avoiding

  • Easing the fall. An eased multi stop keyframe restarts its curve at every stop, so a drop with opacity stops at 5% and 95% slows down twice on the way down. Keep the timing linear and change the distances if you need acceleration.
  • Starting the drop at top: 0. It pops into existence at the top edge instead of entering from above, and the pop is visible on every cycle for every drop.
  • Leaving overflow: hidden off the stage. Drops carry on past the bottom of the box and fall through whatever sits under it, which is easy to miss on a dark background and obvious on a light one.
  • Giving several drops the same duration and only varying the delay. They stay permanently in step, and a group of drops moving as one unit reads as a texture rather than as rain.
  • Putting text inside the stage without a stacking context. The drops paint over it, and translucent streaks passing across letters make the text flicker.

Frequently asked questions

How do you make rain in CSS without JavaScript?
Absolutely position a set of thin elements inside a box with overflow: hidden, give them a vertical gradient from transparent to a pale blue, and animate translateY from above the box to below it. Vary the duration and delay per drop with nth-child selectors.
Why does my rain look like it stutters?
The animation is probably eased. A timing function applies to each keyframe segment separately, so an animation with opacity stops partway through will slow down at every one of them. Set animation-timing-function: linear.
How many raindrops should I use?
Twenty is enough to read as rain in a panel this size. Density scales with area rather than with taste: a full width hero needs proportionally more, and past roughly a hundred animated elements the compositor work starts to show on phones.
Can the rain fall at an angle?
Yes. Move the drop on both axes in the keyframe and rotate it by the same angle its path makes with vertical, so the streak lines up with the direction of travel. The wind variant on this page uses 70px sideways over 240px down, which is about 16 degrees.
Can a drop trigger a splash when it lands?
In pure CSS, only by sharing timing. Give the splash the same animation-duration and animation-delay as its drop and keep its keyframes invisible until the moment the drop reaches the floor. The second variant below fires each ring at 86% of the cycle, which is where its drop lands.