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 Particle Float

Floating particles using multiple elements with staggered CSS animation-delay values. Each particle follows its own timing.

Level
Advanced
Published
Updated
Reading time
8 min

Try it

3 live demos

Twelve particles, one keyframe

Other ways to build it

Build it

6 snippets to copy

CSS Particle Float

HTML

<div class="particle-stage">
  <div class="particle"></div>
  <div class="particle"></div>
  <div class="particle"></div>
  <div class="particle"></div>
  <div class="particle"></div>
  <div class="particle"></div>
  <div class="particle"></div>
  <div class="particle"></div>
  <div class="particle"></div>
  <div class="particle"></div>
  <div class="particle"></div>
  <div class="particle"></div>
</div>

CSS

/* Every particle is absolutely positioned, so the stage
   needs its own width and height or it collapses to nothing. */
.particle-stage {
  width: 100%;
  position: relative;
  height: 240px;
  overflow: hidden;
  border-radius: 12px;
  background: #141415;
}

.particle {
  position: absolute;
  border-radius: 50%;
  animation: particle-rise linear infinite;
  opacity: 0;
}

/* One keyframe, twelve different sizes, speeds and start times */
.particle:nth-child(1)  { left: 8%;  width: 6px; height: 6px; background: #b8ff57; animation-duration: 4s;   animation-delay: 0s; }
.particle:nth-child(2)  { left: 16%; width: 4px; height: 4px; background: #38bdf8; animation-duration: 5.5s; animation-delay: 0.5s; }
.particle:nth-child(3)  { left: 24%; width: 8px; height: 8px; background: #c084fc; animation-duration: 3.8s; animation-delay: 1s; }
.particle:nth-child(4)  { left: 32%; width: 3px; height: 3px; background: #ff6b6b; animation-duration: 6s;   animation-delay: 1.5s; }
.particle:nth-child(5)  { left: 40%; width: 5px; height: 5px; background: #57d9a3; animation-duration: 4.5s; animation-delay: 0.3s; }
.particle:nth-child(6)  { left: 48%; width: 7px; height: 7px; background: #f472b6; animation-duration: 5s;   animation-delay: 0.8s; }
.particle:nth-child(7)  { left: 56%; width: 4px; height: 4px; background: #ffd444; animation-duration: 3.5s; animation-delay: 1.2s; }
.particle:nth-child(8)  { left: 64%; width: 6px; height: 6px; background: #b8ff57; animation-duration: 5.8s; animation-delay: 0.6s; }
.particle:nth-child(9)  { left: 72%; width: 3px; height: 3px; background: #38bdf8; animation-duration: 4.2s; animation-delay: 1.8s; }
.particle:nth-child(10) { left: 80%; width: 8px; height: 8px; background: #ff9040; animation-duration: 4.8s; animation-delay: 0.2s; }
.particle:nth-child(11) { left: 88%; width: 5px; height: 5px; background: #57d9a3; animation-duration: 3.2s; animation-delay: 1.4s; }
.particle:nth-child(12) { left: 94%; width: 4px; height: 4px; background: #c084fc; animation-duration: 5.3s; animation-delay: 0.9s; }

@keyframes particle-rise {
  0% {
    transform: translateY(240px) translateX(0);
    opacity: 0;
  }
  10% {
    opacity: .8;
  }
  90% {
    opacity: .6;
  }
  100% {
    transform: translateY(-20px) translateX(20px);
    opacity: 0;
  }
}

Two axis motion with a wrapper element

An element has one transform, and two animations that both write to it fight over the same property rather than combining. The way round it is to split the motion across two elements: the wrapper handles the vertical lift and the dot inside it handles a sideways sway, each on its own cycle. Because the sway duration is not a multiple of the lift duration, no two passes trace the same path. The sway is eased and the lift is linear, which matches how the two movements behave: a rising particle has no reason to accelerate, a swaying one turns around at each end.

HTML

<div class="pf-sway-stage">
  <div class="pf-sway"><span class="pf-sway-dot"></span></div>
  <div class="pf-sway"><span class="pf-sway-dot"></span></div>
  <!-- ...eight in total -->
</div>

CSS

.pf-sway-stage {
  position: relative;
  width: 100%;
  height: 200px;
  overflow: hidden;
  border-radius: 12px;
  background: #141415;
}

/* the wrapper owns the vertical travel */
.pf-sway {
  position: absolute;
  top: 0;
  animation: pf-lift linear infinite;
}

/* the dot owns the horizontal sway, on its own cycle */
.pf-sway-dot {
  display: block;
  width: 7px;
  height: 7px;
  border-radius: 50%;
  background: #b8ff57;
  animation: pf-sway-x ease-in-out infinite;
}

.pf-sway:nth-child(1) { left: 8%;  animation-duration: 5s;   animation-delay: -1s; }
.pf-sway:nth-child(2) { left: 22%; animation-duration: 6.4s; animation-delay: -3s; }
.pf-sway:nth-child(3) { left: 35%; animation-duration: 4.6s; animation-delay: -2s; }
.pf-sway:nth-child(4) { left: 48%; animation-duration: 7s;   animation-delay: -5s; }

.pf-sway:nth-child(1) .pf-sway-dot { animation-duration: 2.3s; }
.pf-sway:nth-child(2) .pf-sway-dot { animation-duration: 3.1s; }
.pf-sway:nth-child(3) .pf-sway-dot { animation-duration: 2.7s; }
.pf-sway:nth-child(4) .pf-sway-dot { animation-duration: 3.6s; }

@keyframes pf-lift {
  0%   { transform: translateY(200px); opacity: 0; }
  12%  { opacity: .9; }
  88%  { opacity: .5; }
  100% { transform: translateY(-24px); opacity: 0; }
}

@keyframes pf-sway-x {
  0%, 100% { transform: translateX(-14px); }
  50%      { transform: translateX(14px); }
}

@media (prefers-reduced-motion: reduce) {
  .pf-sway,
  .pf-sway-dot { animation: none; }
}

Falling snow from the same keyframes, reversed

Adding reverse to the animation shorthand plays the keyframes back to front, so the rule that lifts a particle from the bottom of the stage to the top drops it from the top to the bottom instead. The opacity ramp reverses with it, which means the fade in and fade out land at the correct ends without editing a single keyframe. Longer durations sell it as snow rather than rain: the same shape of movement at three times the speed reads as weather with a lot more force behind it. Compare the CSS rain effect, which is the fast version of this idea with a stretched drop instead of a round one.

HTML

<div class="pf-snow-stage">
  <div class="pf-snow"></div>
  <div class="pf-snow"></div>
  <div class="pf-snow"></div>
  <div class="pf-snow"></div>
  <div class="pf-snow"></div>
  <div class="pf-snow"></div>
  <div class="pf-snow"></div>
  <div class="pf-snow"></div>
  <div class="pf-snow"></div>
  <div class="pf-snow"></div>
  <div class="pf-snow"></div>
  <div class="pf-snow"></div>
</div>

CSS

/* uses @keyframes particle-rise from the main example above; the rules here are
   what changes, not the whole file */
.pf-snow-stage {
  position: relative;
  width: 100%;
  height: 240px;
  overflow: hidden;
  border-radius: 12px;
  background: linear-gradient(180deg, #0a0e14 0%, #10161f 100%);
}

/* reverse sits inside the shorthand on purpose: writing
   animation-direction on its own line before the shorthand
   would be wiped out by it */
.pf-snow {
  position: absolute;
  top: 0;
  border-radius: 50%;
  background: #f0f0f0;
  animation: particle-rise linear infinite reverse;
}

.pf-snow:nth-child(1) { left: 6%;  width: 5px; height: 5px; animation-duration: 8s;    animation-delay: -2s; }
.pf-snow:nth-child(2) { left: 17%; width: 3px; height: 3px; animation-duration: 11s;   animation-delay: -6s; }
.pf-snow:nth-child(3) { left: 29%; width: 6px; height: 6px; animation-duration: 9.5s;  animation-delay: -4s; }
.pf-snow:nth-child(4) { left: 41%; width: 4px; height: 4px; animation-duration: 12.5s; animation-delay: -1s; }

@media (prefers-reduced-motion: reduce) {
  .pf-snow { animation: none; opacity: .5; }
}

Understand it

How it works and 7 properties

A CSS particle effect is a field of small absolutely positioned elements that all share one @keyframes rule. Nothing about the animation itself changes from particle to particle. What varies is where each one starts, how big it is, how long its cycle lasts and how far into that cycle it begins, and those four numbers are enough to make twelve identical instructions look like twelve independent objects.

The field below rises and fades on a loop with no script involved. Two more builds follow: a two axis version that adds a sideways sway by splitting the motion across a parent and a child, and falling snow made by running the same keyframes backwards.

How it works

Each particle is a small <div> with position: absolute and border-radius: 50%. The @keyframes rule moves it from the bottom of the container to the top with translateY while fading in and out through opacity. animation-delay offsets each particle's start time and different animation-duration values give each one a different speed, which is what turns a single keyframe definition into a field of independent movement.

Twelve elements, one animation. Every particle carries animation: particle-rise linear infinite from the shared rule, and a run of nth-child selectors then sets left, width, height, background, animation-duration and animation-delay per particle. Adding a thirteenth is one more selector rather than one more keyframe block, and changing the motion for all of them is a single edit. That separation is the whole reason the pattern scales: the shape of the movement lives in one place and the variation lives in another.

The stage needs an explicit height. Every particle is out of flow, so the container contributes nothing to its own size and collapses to zero without one. overflow: hidden clips a particle once it passes the top edge, which is what keeps the effect inside its box instead of floating over the paragraph above. The 240px in the keyframe is tied to that height by hand, and it is the one coupling in the file worth commenting: change the stage to 320px and the particles will start 80px up from the bottom until you change the keyframe to match.

Delays here are positive, which means the stage fills up over the first two seconds rather than being full on the first frame. Because every particle starts at opacity: 0, the fill reads as a fade in and not as a queue, so the tradeoff is acceptable. A negative delay does the opposite: it starts the particle partway through its cycle immediately, so the field is fully populated the moment the page paints. Use negative delays when the effect sits above the fold and positive ones when the reader will scroll to it. The same choice comes up on the morphing blob.

The opacity ramp inside the keyframe is doing more work than it looks. A particle is invisible at 0% and 100%, reaches 0.8 by 10% and drifts down to 0.6 by 90%. Since the position at the start and the end of the cycle are different points on the screen, a particle that was still visible at 100% would appear to teleport back to the bottom every loop. Fading to zero at both ends hides the seam. Any looping motion that does not return to its starting position needs this, and forgetting it is the most common reason a particle field looks like it is stuttering.

Only transform and opacity are animated, and those are the two properties a browser can change without laying out or repainting anything. left is set once per particle and never touched again. Swapping the translateY for an animated top would look identical and force a layout pass on every particle on every frame, which is the difference between a free effect and one that visibly costs the reader battery. The CSS rain effect is the same technique tuned for speed instead of drift.

Accessibility notes

A particle field is decoration and should be marked as such. Give the stage aria-hidden="true" so a screen reader does not walk through twelve empty elements, and keep it out of the tab order by never putting anything focusable inside it. If the particles are conveying something real, such as a success state, put that meaning in text and let the animation sit behind it.

Continuous motion behind or beside body text makes reading measurably harder for people with attention or processing difficulties, and unlike a one shot transition it never resolves. Wrap the animation in a prefers-reduced-motion check. The two variants below stop entirely under reduce rather than slowing down, because a slower version of an endless movement is still an endless movement.

If particles pass over text, contrast is no longer a fixed number. A 0.8 opacity dot crossing a paragraph changes the effective contrast of the characters it covers for as long as it takes to pass. Keep particles behind a solid or near solid surface, or keep the text off the animated area altogether.

Mistakes worth avoiding

  • Leaving the stage with no height. The particles are absolutely positioned, so the container has nothing to size itself from and collapses to zero, which looks like the CSS never loaded.
  • Forgetting overflow: hidden. Particles keep going past the edges of the stage and drift over headings and text elsewhere on the page, and because they are decorative nobody notices until a screenshot shows a dot in the middle of a paragraph.
  • Animating top or bottom rather than transform. The result looks the same and forces a layout pass per particle per frame, which is where a field of forty particles turns into a slideshow on a mid range phone.
  • Letting opacity finish above zero. The particle is still visible when the cycle restarts, so it jumps from the top of the stage back to the bottom in one frame, over and over.
  • Hard coding the travel distance and then changing the stage height. The keyframe still moves the old number of pixels, so the particles either stop short of the top or start below the bottom edge.

Ship it

5 features in 4 browsers, 5 questions

Browser support

Feature Chrome Firefox Safari Edge
animation455.112
transform43.53.112
opacity423.112
:nth-child()43.53.212
prefers-reduced-motion746310.179

Figures come from Can I Use. Every property involved has been supported for well over a decade, so there is no fallback to write and no prefix to add. The only modern piece is prefers-reduced-motion, and in a browser too old to understand the media query the block is ignored, which leaves the animation running rather than breaking the page.

What you can build with it

  • Hero backdrops. A slow field behind a headline gives a flat section depth without loading an image or a canvas library.
  • Empty states. A few drifting dots in an empty inbox or dashboard panel reads as calm rather than broken.
  • Success confirmation. A short burst after a form submits, played once instead of on a loop, which is a particle field with animation-iteration-count: 1.
  • Ambient loading. Motion during a long request that does not imply a percentage the way a progress bar does.
  • Seasonal effects. Snow, embers or dust, all the same keyframe with different direction, color and speed. The snow variant below is one line of change.

Frequently asked questions

Can you make a particle effect without JavaScript?
Yes, a particle effect works without JavaScript. A particle field is a set of absolutely positioned elements sharing one @keyframes rule, with animation-delay and animation-duration varied per element. Script only becomes necessary when particles need to react to the pointer or to random values that change on each load.
Why do all my particles move identically?
The particles share a duration as well as a keyframe. Delays alone are not enough: two particles on the same duration stay permanently in step, only offset. Give each one a different duration, ideally not a multiple of any other, and they will never realign.
How many particles is too many?
Each animated particle is a composited layer. A dozen costs nothing measurable. Somewhere past a hundred the compositor memory and the per frame work start to show, sooner on a phone than a laptop. If you need hundreds, a canvas is the right tool.
Why do my particles jump at the end of the loop?
The opacity does not reach zero at both ends of the keyframe. Since the last frame and the first frame are at different positions, a particle that is still visible when the cycle restarts appears to teleport. Set opacity: 0 at both 0% and 100%.
How do I make particles fall instead of rise?
Add reverse to the animation shorthand. The keyframes play back to front, so the same rule that lifts a particle from the bottom to the top drops it from the top to the bottom, opacity ramp included. The snow variant on this page does exactly that.