CSS Rain Effect
Animated rain streaks using absolutely-positioned elements and staggered @keyframes.
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
positionabsoluteon every drop andrelativeon the stage. The drops leave the flow entirely, so the stage needs a height set by hand.transformtranslateYfor the fall andscaleXto 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.
overflowhiddenon 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
| Feature | Chrome | Firefox | Safari | Edge |
|---|---|---|---|---|
animation | 4 | 5 | 5.1 | 12 |
transform | 4 | 3.5 | 3.1 | 12 |
opacity | 4 | 2 | 3.1 | 12 |
:nth-child() | 4 | 3.5 | 3.2 | 12 |
prefers-reduced-motion | 74 | 63 | 10.1 | 79 |
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
linearand 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: hiddenoff 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?
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?
animation-timing-function: linear.How many raindrops should I use?
Can the rain fall at an angle?
Can a drop trigger a splash when it lands?
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.