CSS Particle Float

Floating particles using multiple elements with staggered CSS animation-delay values.

Published June 29, 2026 Advanced 8 min read

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.

Twelve particles, one keyframe

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;
  }
}

Other ways to build it

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 actually 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; }
}

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.

CSS properties used

position
absolute on each particle and relative on the stage. This takes the particles out of flow, which is why the stage needs a height of its own.
transform
translateY moves the particle up the stage and a small translateX adds drift. Transforms are composited, so the movement costs no layout.
opacity
Ramped inside the keyframe so the particle is invisible at both ends of the cycle, which hides the jump back to the start.
animation-duration
Set per particle between 3.2s and 6s. Different durations mean the field never falls into a single visible rhythm.
animation-delay
Offsets each particle's start. Positive values fill the stage over the first couple of seconds, negative values fill it immediately.
animation-timing-function
linear, because a rising particle has no reason to accelerate. Easing applies to each keyframe segment separately, so an eased multi stop rise would stall at every stop.
overflow
hidden on the stage clips particles at the edges. Without it they carry on over whatever sits above the container.

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. 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 simply ignored, which leaves the animation running rather than breaking the page.

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.

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.

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.

Frequently asked questions

Can you make a particle effect without JavaScript?
Yes. 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?
They share a duration as well as a keyframe. Delays alone are not enough: two particles on the same duration stay permanently in step, just 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.