CSS Confetti Burst
CSS confetti animation using transform, @keyframes, and nth-child for staggered timing.
CSS confetti is a handful of small absolutely positioned boxes, each falling on its own schedule, inside a container with overflow: hidden to clip them at the edges. One @keyframes animation moves every piece. What makes them look independent is nth-child, which hands each one a different column, color, shape, duration and delay.
CSS has no random function, so nothing here is actually random. The irregularity here comes from five nth-child cycles of different lengths running over the same set of elements, which is as close to a random scatter as CSS gets without a random function. What it buys is an effect with no script, no canvas and no library, running entirely on the compositor. The variants below fire a cannon that can be aimed from either corner or from the middle, and throw the pieces outward from a center point instead of dropping them from the top.
Thirty-two pieces from five cycles
🎉 Congrats!
HTML
<div class="confetti-stage">
<div class="confetti-field" aria-hidden="true">
<!-- the demo above uses 32. No rule below names a piece number,
so any count works; more pieces just means denser confetti -->
<i class="piece"></i>
<i class="piece"></i>
<i class="piece"></i>
<i class="piece"></i>
<i class="piece"></i>
<i class="piece"></i>
<i class="piece"></i>
<i class="piece"></i>
<i class="piece"></i>
<i class="piece"></i>
<i class="piece"></i>
<i class="piece"></i>
<i class="piece"></i>
<i class="piece"></i>
<i class="piece"></i>
<i class="piece"></i>
<i class="piece"></i>
<i class="piece"></i>
<i class="piece"></i>
<i class="piece"></i>
<i class="piece"></i>
<i class="piece"></i>
<i class="piece"></i>
<i class="piece"></i>
<i class="piece"></i>
<i class="piece"></i>
<i class="piece"></i>
<i class="piece"></i>
<i class="piece"></i>
<i class="piece"></i>
<i class="piece"></i>
<i class="piece"></i>
</div>
<p class="confetti-text">🎉 Congrats!</p>
</div>
CSS
.confetti-stage {
position: relative;
width: 100%;
height: 240px;
overflow: hidden;
background: #141415;
display: flex;
align-items: center;
justify-content: center;
}
.confetti-text {
position: relative;
z-index: 1;
font-size: 1.25rem;
font-weight: 700;
color: #f0f0f0;
}
/* The column a piece falls in comes from flex, not from a rule.
space-between spreads however many pieces are there, so adding
one needs no CSS. position: relative keeps them in that flow
while the negative top lifts them above the frame. */
.confetti-field {
position: absolute;
inset: 0 3%;
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.piece {
position: relative;
top: -18px;
flex: 0 0 auto;
border-radius: 2px;
animation: confetti-fall 1.8s linear infinite;
}
/* CSS has no random(). It does have nth-child cycles, and cycles
whose lengths share no common factor behave like one.
Colors repeat every 8, shapes every 5, durations every 7, delays
every 11, the nudge every 3. Two pieces match only when all five
line up again: 8 x 5 x 7 x 11 x 3 = 9240 pieces apart.
The values inside each cycle are out of order on purpose. In
order the cycles march in step at first and it reads as a
gradient instead of a scatter. */
/* color, every 8 */
.piece:nth-child(8n+1) { background: #ff6b6b; }
.piece:nth-child(8n+2) { background: #38bdf8; }
.piece:nth-child(8n+3) { background: #ffd444; }
.piece:nth-child(8n+4) { background: #b8ff57; }
.piece:nth-child(8n+5) { background: #f472b6; }
.piece:nth-child(8n+6) { background: #57d9a3; }
.piece:nth-child(8n+7) { background: #c084fc; }
.piece:nth-child(8n) { background: #ff9040; }
/* shape, every 5 */
.piece:nth-child(5n+1) { width: 8px; height: 12px; }
.piece:nth-child(5n+2) { width: 12px; height: 8px; border-radius: 50%; }
.piece:nth-child(5n+3) { width: 6px; height: 14px; }
.piece:nth-child(5n+4) { width: 14px; height: 6px; }
.piece:nth-child(5n) { width: 9px; height: 9px; border-radius: 50%; }
/* fall time, every 7 */
.piece:nth-child(7n+1) { animation-duration: 1.86s; }
.piece:nth-child(7n+2) { animation-duration: 1.5s; }
.piece:nth-child(7n+3) { animation-duration: 2.1s; }
.piece:nth-child(7n+4) { animation-duration: 1.62s; }
.piece:nth-child(7n+5) { animation-duration: 2.22s; }
.piece:nth-child(7n+6) { animation-duration: 1.74s; }
.piece:nth-child(7n) { animation-duration: 1.98s; }
/* start offset, every 11. All negative, which starts a piece part
way through its own fall instead of making it wait, so the first
frame is already full. */
.piece:nth-child(11n+1) { animation-delay: -0.9s; }
.piece:nth-child(11n+2) { animation-delay: -1.7s; }
.piece:nth-child(11n+3) { animation-delay: -0.3s; }
.piece:nth-child(11n+4) { animation-delay: -2.05s; }
.piece:nth-child(11n+5) { animation-delay: -1.2s; }
.piece:nth-child(11n+6) { animation-delay: -0.55s; }
.piece:nth-child(11n+7) { animation-delay: -1.95s; }
.piece:nth-child(11n+8) { animation-delay: -0.75s; }
.piece:nth-child(11n+9) { animation-delay: -1.45s; }
.piece:nth-child(11n+10) { animation-delay: -0.15s; }
.piece:nth-child(11n) { animation-delay: -2.3s; }
/* a nudge off the even spacing, every 3 */
.piece:nth-child(3n+2) { margin-left: 1.4%; }
.piece:nth-child(3n) { margin-right: 1.8%; }
/* A timing function applies to each keyframe segment, not to the
animation as a whole, so an eased segment restarts its curve at
every stop and the piece stalls mid-fall. Timing stays linear and
the acceleration lives in the distances: 260px * t squared. */
@keyframes confetti-fall {
0% { transform: translateY(0) rotate(0deg) scaleX(1); opacity: 1; }
25% { transform: translateY(16px) rotate(90deg) scaleX(0); }
50% { transform: translateY(65px) rotate(180deg) scaleX(-1); }
75% { transform: translateY(146px) rotate(270deg) scaleX(0); opacity: 1; }
100% { transform: translateY(260px) rotate(360deg) scaleX(1); opacity: 0; }
}
Other ways to build it
A cannon, with a direction
A burst is an event rather than a state, so looping forever is usually the wrong default. This one fires eighteen pieces on their own arcs, and the whole direction feature is two custom properties: where the muzzle sits, and which way the shot travels. The radios set them on the stage and every piece inherits, so not one piece rule mentions a direction. Fired from the middle, nth-child(even) flips half the pieces the other way. Pick a direction and the snippet below changes with it, because the code blocks are shown by the same :checked rule that moves the cannon. The fire control is worth a look too. A single checkbox latches, so the second click switches the shot off rather than firing again. This uses two radios instead, with two labels stacked in the same place and only one shown at a time, each pointing at whichever radio is not currently checked. Every click therefore lands on a different radio, which changes the animation-name between two blocks that describe the same motion, and a changed name starts from zero. That is the closest CSS gets to replaying an animation on demand. What it does not fix is the semantics: to a screen reader this is still a radio group rather than a button, and the keyboard fires it with the arrow keys.
HTML
<!-- the direction radios sit outside the demo so they can reach
both the stage and the code block below it -->
<input type="radio" name="cn-dir" id="cn-left" class="cn-radio cn-r-left" checked>
<input type="radio" name="cn-dir" id="cn-mid" class="cn-radio cn-r-mid">
<input type="radio" name="cn-dir" id="cn-right" class="cn-radio cn-r-right">
<div class="cn-tabs" role="group" aria-label="Cannon direction">
<label for="cn-left">From the left</label>
<label for="cn-mid">From the center</label>
<label for="cn-right">From the right</label>
</div>
<!-- two radios, two labels in the same spot, one shown at a time -->
<input type="radio" name="cn-fire" id="cn-fire-a" class="cn-fire cn-fire-a">
<input type="radio" name="cn-fire" id="cn-fire-b" class="cn-fire cn-fire-b">
<label for="cn-fire-a" class="confetti-btn cn-btn-a">Fire the cannon</label>
<label for="cn-fire-b" class="confetti-btn cn-btn-b">Fire the cannon</label>
<div class="confetti-stage cannon-stage" aria-hidden="true">
<!-- eighteen, one per nth-child rule below; each carries its own
reach and apex -->
<div class="cannon-piece"></div>
<div class="cannon-piece"></div>
<div class="cannon-piece"></div>
<div class="cannon-piece"></div>
<div class="cannon-piece"></div>
<div class="cannon-piece"></div>
<div class="cannon-piece"></div>
<div class="cannon-piece"></div>
<div class="cannon-piece"></div>
<div class="cannon-piece"></div>
<div class="cannon-piece"></div>
<div class="cannon-piece"></div>
<div class="cannon-piece"></div>
<div class="cannon-piece"></div>
<div class="cannon-piece"></div>
<div class="cannon-piece"></div>
<div class="cannon-piece"></div>
<div class="cannon-piece"></div>
<p class="confetti-text">Order confirmed</p>
</div>
CSS
/* fired from the bottom left, travelling right */
.cannon-stage {
--cn-x: 16px;
--cn-dir: 1;
}
.cannon-piece {
position: absolute;
left: var(--cn-x);
bottom: 16px;
opacity: 0;
animation: confetti-cannon 1.6s linear 1 forwards;
animation-name: none;
}
/* Two labels in the same place, one shown at a time, each pointing at
the radio that is not currently checked. That is what makes a second
click fire again rather than switching the first shot off. */
.cn-btn-b { display: none; }
.cn-fire-a:checked ~ .cn-btn-a { display: none; }
.cn-fire-a:checked ~ .cn-btn-b { display: inline-block; }
/* CSS cannot restart an animation on demand. Two names for the same
motion is the way around it: each click checks the other radio, the
animation-name changes, and a changed name starts from zero. */
.cn-fire-a:checked ~ .cannon-stage .cannon-piece {
animation-name: confetti-cannon;
}
.cn-fire-b:checked ~ .cannon-stage .cannon-piece {
animation-name: confetti-cannon-again;
}
/* one block for every piece and every direction. The horizontal term
is multiplied by --cn-dir, so flipping that one number mirrors the
whole shot. Every stop is a calc against the same variables,
including the zeroes: a plain length and an unresolved calc do not
reliably interpolate against each other. */
@keyframes confetti-cannon {
0% { transform: translate(calc(var(--dx) * 0 * var(--cn-dir)), calc(var(--peak) * 0)) rotate(0deg) scale(.35); opacity: 0; }
8% { transform: translate(calc(var(--dx) * .12 * var(--cn-dir)), calc(var(--peak) * -.42)) rotate(60deg) scale(1); opacity: 1; }
45% { transform: translate(calc(var(--dx) * .55 * var(--cn-dir)), calc(var(--peak) * -1)) rotate(300deg) scale(1); }
65% { transform: translate(calc(var(--dx) * .75 * var(--cn-dir)), calc(var(--peak) * -.82)) rotate(420deg) scale(1); opacity: 1; }
100% { transform: translate(calc(var(--dx) * 1 * var(--cn-dir)), calc(var(--peak) * .55)) rotate(620deg) scale(1); opacity: 0; }
}
/* identical to the block above apart from the name. That is the whole
trick: a changed animation-name restarts the animation, and nothing
else in CSS will. */
@keyframes confetti-cannon-again {
0% { transform: translate(calc(var(--dx) * 0 * var(--cn-dir)), calc(var(--peak) * 0)) rotate(0deg) scale(.35); opacity: 0; }
8% { transform: translate(calc(var(--dx) * .12 * var(--cn-dir)), calc(var(--peak) * -.42)) rotate(60deg) scale(1); opacity: 1; }
45% { transform: translate(calc(var(--dx) * .55 * var(--cn-dir)), calc(var(--peak) * -1)) rotate(300deg) scale(1); }
65% { transform: translate(calc(var(--dx) * .75 * var(--cn-dir)), calc(var(--peak) * -.82)) rotate(420deg) scale(1); opacity: 1; }
100% { transform: translate(calc(var(--dx) * 1 * var(--cn-dir)), calc(var(--peak) * .55)) rotate(620deg) scale(1); opacity: 0; }
}
/* angle and power per piece, shared by all three directions */
.cannon-piece:nth-child(1) { --dx: 386px; --peak: 35px; background: #c084fc; }
.cannon-piece:nth-child(2) { --dx: 422px; --peak: 46px; background: #ffd444; }
/* sixteen more, spreading from a flat shot to a steep one */
HTML
<!-- the direction radios sit outside the demo so they can reach
both the stage and the code block below it -->
<input type="radio" name="cn-dir" id="cn-left" class="cn-radio cn-r-left" checked>
<input type="radio" name="cn-dir" id="cn-mid" class="cn-radio cn-r-mid">
<input type="radio" name="cn-dir" id="cn-right" class="cn-radio cn-r-right">
<div class="cn-tabs" role="group" aria-label="Cannon direction">
<label for="cn-left">From the left</label>
<label for="cn-mid">From the center</label>
<label for="cn-right">From the right</label>
</div>
<!-- two radios, two labels in the same spot, one shown at a time -->
<input type="radio" name="cn-fire" id="cn-fire-a" class="cn-fire cn-fire-a">
<input type="radio" name="cn-fire" id="cn-fire-b" class="cn-fire cn-fire-b">
<label for="cn-fire-a" class="confetti-btn cn-btn-a">Fire the cannon</label>
<label for="cn-fire-b" class="confetti-btn cn-btn-b">Fire the cannon</label>
<div class="confetti-stage cannon-stage" aria-hidden="true">
<!-- eighteen, one per nth-child rule below; each carries its own
reach and apex -->
<div class="cannon-piece"></div>
<div class="cannon-piece"></div>
<div class="cannon-piece"></div>
<div class="cannon-piece"></div>
<div class="cannon-piece"></div>
<div class="cannon-piece"></div>
<div class="cannon-piece"></div>
<div class="cannon-piece"></div>
<div class="cannon-piece"></div>
<div class="cannon-piece"></div>
<div class="cannon-piece"></div>
<div class="cannon-piece"></div>
<div class="cannon-piece"></div>
<div class="cannon-piece"></div>
<div class="cannon-piece"></div>
<div class="cannon-piece"></div>
<div class="cannon-piece"></div>
<div class="cannon-piece"></div>
<p class="confetti-text">Order confirmed</p>
</div>
CSS
/* fired from the middle, half the pieces each way */
.cannon-stage {
--cn-x: 50%;
--cn-dir: 1;
}
.cannon-piece {
position: absolute;
left: var(--cn-x);
bottom: 16px;
opacity: 0;
animation: confetti-cannon 1.6s linear 1 forwards;
animation-name: none;
}
/* Two labels in the same place, one shown at a time, each pointing at
the radio that is not currently checked. That is what makes a second
click fire again rather than switching the first shot off. */
.cn-btn-b { display: none; }
.cn-fire-a:checked ~ .cn-btn-a { display: none; }
.cn-fire-a:checked ~ .cn-btn-b { display: inline-block; }
/* CSS cannot restart an animation on demand. Two names for the same
motion is the way around it: each click checks the other radio, the
animation-name changes, and a changed name starts from zero. */
.cn-fire-a:checked ~ .cannon-stage .cannon-piece {
animation-name: confetti-cannon;
}
.cn-fire-b:checked ~ .cannon-stage .cannon-piece {
animation-name: confetti-cannon-again;
}
/* fired from the middle, half the pieces go each way */
.cannon-stage .cannon-piece:nth-child(even) {
--cn-dir: -1;
}
/* one block for every piece and every direction. The horizontal term
is multiplied by --cn-dir, so flipping that one number mirrors the
whole shot. Every stop is a calc against the same variables,
including the zeroes: a plain length and an unresolved calc do not
reliably interpolate against each other. */
@keyframes confetti-cannon {
0% { transform: translate(calc(var(--dx) * 0 * var(--cn-dir)), calc(var(--peak) * 0)) rotate(0deg) scale(.35); opacity: 0; }
8% { transform: translate(calc(var(--dx) * .12 * var(--cn-dir)), calc(var(--peak) * -.42)) rotate(60deg) scale(1); opacity: 1; }
45% { transform: translate(calc(var(--dx) * .55 * var(--cn-dir)), calc(var(--peak) * -1)) rotate(300deg) scale(1); }
65% { transform: translate(calc(var(--dx) * .75 * var(--cn-dir)), calc(var(--peak) * -.82)) rotate(420deg) scale(1); opacity: 1; }
100% { transform: translate(calc(var(--dx) * 1 * var(--cn-dir)), calc(var(--peak) * .55)) rotate(620deg) scale(1); opacity: 0; }
}
/* identical to the block above apart from the name. That is the whole
trick: a changed animation-name restarts the animation, and nothing
else in CSS will. */
@keyframes confetti-cannon-again {
0% { transform: translate(calc(var(--dx) * 0 * var(--cn-dir)), calc(var(--peak) * 0)) rotate(0deg) scale(.35); opacity: 0; }
8% { transform: translate(calc(var(--dx) * .12 * var(--cn-dir)), calc(var(--peak) * -.42)) rotate(60deg) scale(1); opacity: 1; }
45% { transform: translate(calc(var(--dx) * .55 * var(--cn-dir)), calc(var(--peak) * -1)) rotate(300deg) scale(1); }
65% { transform: translate(calc(var(--dx) * .75 * var(--cn-dir)), calc(var(--peak) * -.82)) rotate(420deg) scale(1); opacity: 1; }
100% { transform: translate(calc(var(--dx) * 1 * var(--cn-dir)), calc(var(--peak) * .55)) rotate(620deg) scale(1); opacity: 0; }
}
/* angle and power per piece, shared by all three directions */
.cannon-piece:nth-child(1) { --dx: 386px; --peak: 35px; background: #c084fc; }
.cannon-piece:nth-child(2) { --dx: 422px; --peak: 46px; background: #ffd444; }
/* sixteen more, spreading from a flat shot to a steep one */
HTML
<!-- the direction radios sit outside the demo so they can reach
both the stage and the code block below it -->
<input type="radio" name="cn-dir" id="cn-left" class="cn-radio cn-r-left" checked>
<input type="radio" name="cn-dir" id="cn-mid" class="cn-radio cn-r-mid">
<input type="radio" name="cn-dir" id="cn-right" class="cn-radio cn-r-right">
<div class="cn-tabs" role="group" aria-label="Cannon direction">
<label for="cn-left">From the left</label>
<label for="cn-mid">From the center</label>
<label for="cn-right">From the right</label>
</div>
<!-- two radios, two labels in the same spot, one shown at a time -->
<input type="radio" name="cn-fire" id="cn-fire-a" class="cn-fire cn-fire-a">
<input type="radio" name="cn-fire" id="cn-fire-b" class="cn-fire cn-fire-b">
<label for="cn-fire-a" class="confetti-btn cn-btn-a">Fire the cannon</label>
<label for="cn-fire-b" class="confetti-btn cn-btn-b">Fire the cannon</label>
<div class="confetti-stage cannon-stage" aria-hidden="true">
<!-- eighteen, one per nth-child rule below; each carries its own
reach and apex -->
<div class="cannon-piece"></div>
<div class="cannon-piece"></div>
<div class="cannon-piece"></div>
<div class="cannon-piece"></div>
<div class="cannon-piece"></div>
<div class="cannon-piece"></div>
<div class="cannon-piece"></div>
<div class="cannon-piece"></div>
<div class="cannon-piece"></div>
<div class="cannon-piece"></div>
<div class="cannon-piece"></div>
<div class="cannon-piece"></div>
<div class="cannon-piece"></div>
<div class="cannon-piece"></div>
<div class="cannon-piece"></div>
<div class="cannon-piece"></div>
<div class="cannon-piece"></div>
<div class="cannon-piece"></div>
<p class="confetti-text">Order confirmed</p>
</div>
CSS
/* fired from the bottom right, travelling left */
.cannon-stage {
--cn-x: calc(100% - 16px);
--cn-dir: -1;
}
.cannon-piece {
position: absolute;
left: var(--cn-x);
bottom: 16px;
opacity: 0;
animation: confetti-cannon 1.6s linear 1 forwards;
animation-name: none;
}
/* Two labels in the same place, one shown at a time, each pointing at
the radio that is not currently checked. That is what makes a second
click fire again rather than switching the first shot off. */
.cn-btn-b { display: none; }
.cn-fire-a:checked ~ .cn-btn-a { display: none; }
.cn-fire-a:checked ~ .cn-btn-b { display: inline-block; }
/* CSS cannot restart an animation on demand. Two names for the same
motion is the way around it: each click checks the other radio, the
animation-name changes, and a changed name starts from zero. */
.cn-fire-a:checked ~ .cannon-stage .cannon-piece {
animation-name: confetti-cannon;
}
.cn-fire-b:checked ~ .cannon-stage .cannon-piece {
animation-name: confetti-cannon-again;
}
/* one block for every piece and every direction. The horizontal term
is multiplied by --cn-dir, so flipping that one number mirrors the
whole shot. Every stop is a calc against the same variables,
including the zeroes: a plain length and an unresolved calc do not
reliably interpolate against each other. */
@keyframes confetti-cannon {
0% { transform: translate(calc(var(--dx) * 0 * var(--cn-dir)), calc(var(--peak) * 0)) rotate(0deg) scale(.35); opacity: 0; }
8% { transform: translate(calc(var(--dx) * .12 * var(--cn-dir)), calc(var(--peak) * -.42)) rotate(60deg) scale(1); opacity: 1; }
45% { transform: translate(calc(var(--dx) * .55 * var(--cn-dir)), calc(var(--peak) * -1)) rotate(300deg) scale(1); }
65% { transform: translate(calc(var(--dx) * .75 * var(--cn-dir)), calc(var(--peak) * -.82)) rotate(420deg) scale(1); opacity: 1; }
100% { transform: translate(calc(var(--dx) * 1 * var(--cn-dir)), calc(var(--peak) * .55)) rotate(620deg) scale(1); opacity: 0; }
}
/* identical to the block above apart from the name. That is the whole
trick: a changed animation-name restarts the animation, and nothing
else in CSS will. */
@keyframes confetti-cannon-again {
0% { transform: translate(calc(var(--dx) * 0 * var(--cn-dir)), calc(var(--peak) * 0)) rotate(0deg) scale(.35); opacity: 0; }
8% { transform: translate(calc(var(--dx) * .12 * var(--cn-dir)), calc(var(--peak) * -.42)) rotate(60deg) scale(1); opacity: 1; }
45% { transform: translate(calc(var(--dx) * .55 * var(--cn-dir)), calc(var(--peak) * -1)) rotate(300deg) scale(1); }
65% { transform: translate(calc(var(--dx) * .75 * var(--cn-dir)), calc(var(--peak) * -.82)) rotate(420deg) scale(1); opacity: 1; }
100% { transform: translate(calc(var(--dx) * 1 * var(--cn-dir)), calc(var(--peak) * .55)) rotate(620deg) scale(1); opacity: 0; }
}
/* angle and power per piece, shared by all three directions */
.cannon-piece:nth-child(1) { --dx: 386px; --peak: 35px; background: #c084fc; }
.cannon-piece:nth-child(2) { --dx: 422px; --peak: 46px; background: #ffd444; }
/* sixteen more, spreading from a flat shot to a steep one */
Radial burst from the center
Every piece starts stacked in the middle of the stage and travels out to its own destination. Those destinations are twelve points thirty degrees apart on a circle, and each one is stored on the piece as two custom properties rather than being written into twelve separate keyframe blocks. One @keyframes rule then reads var(--tx) and var(--ty), so adding a thirteenth piece means one more nth-child line and no new animation. The timing is linear for the same reason the falling version is, and the slowdown is written into the distances instead: each stop sits at a fraction of the full travel that follows 1 - (1 - t)², which is the shape of something losing momentum.
HTML
<div class="confetti-stage confetti-burst-stage" aria-hidden="true">
<!-- twelve, one per nth-child rule; each stores its own
destination in --tx and --ty -->
<div class="burst-piece"></div>
<div class="burst-piece"></div>
<div class="burst-piece"></div>
<div class="burst-piece"></div>
<div class="burst-piece"></div>
<div class="burst-piece"></div>
<div class="burst-piece"></div>
<div class="burst-piece"></div>
<div class="burst-piece"></div>
<div class="burst-piece"></div>
<div class="burst-piece"></div>
<div class="burst-piece"></div>
<p class="confetti-text">Level cleared</p>
</div>
CSS
.burst-piece {
position: absolute;
top: 50%;
left: 50%;
width: 9px;
height: 9px;
border-radius: 2px;
animation: confetti-burst 1.8s linear infinite;
}
/* one keyframe block for every piece, because the destination is
data on the element rather than part of the animation. linear
timing, with the slowdown written into the distances as
1 - (1 - t)², for the same reason the falling version uses it */
@keyframes confetti-burst {
0% {
transform: translate(-50%, -50%) rotate(0deg) scale(.3);
opacity: 0;
}
10% {
transform:
translate(calc(-50% + var(--tx) * .19), calc(-50% + var(--ty) * .19))
rotate(54deg) scale(1);
opacity: 1;
}
25% {
transform:
translate(calc(-50% + var(--tx) * .44), calc(-50% + var(--ty) * .44))
rotate(135deg) scale(1);
}
50% {
transform:
translate(calc(-50% + var(--tx) * .75), calc(-50% + var(--ty) * .75))
rotate(270deg) scale(1);
}
75% {
transform:
translate(calc(-50% + var(--tx) * .94), calc(-50% + var(--ty) * .94))
rotate(405deg) scale(1);
opacity: 1;
}
100% {
transform:
translate(calc(-50% + var(--tx)), calc(-50% + var(--ty)))
rotate(540deg) scale(1);
opacity: 0;
}
}
/* twelve points, thirty degrees apart, on a 90px circle */
.burst-piece:nth-child(1) { --tx: 90px; --ty: 0px; background: #ff6b6b; }
.burst-piece:nth-child(2) { --tx: 78px; --ty: 45px; background: #b8ff57; }
.burst-piece:nth-child(3) { --tx: 45px; --ty: 78px; background: #38bdf8; }
.burst-piece:nth-child(4) { --tx: 0px; --ty: 90px; background: #c084fc; }
.burst-piece:nth-child(5) { --tx: -45px; --ty: 78px; background: #ffd444; }
.burst-piece:nth-child(6) { --tx: -78px; --ty: 45px; background: #f472b6; }
.burst-piece:nth-child(7) { --tx: -90px; --ty: 0px; background: #57d9a3; }
.burst-piece:nth-child(8) { --tx: -78px; --ty: -45px; background: #ff9040; }
.burst-piece:nth-child(9) { --tx: -45px; --ty: -78px; background: #b8ff57; }
.burst-piece:nth-child(10) { --tx: 0px; --ty: -90px; background: #38bdf8; }
.burst-piece:nth-child(11) { --tx: 45px; --ty: -78px; background: #ff6b6b; }
.burst-piece:nth-child(12) { --tx: 78px; --ty: -45px; background: #c084fc; }
@media (prefers-reduced-motion: reduce) {
.burst-piece { display: none; }
}
How it works
Every piece is the same empty element with the same class. The column it falls in comes from flex rather than from a rule, because the field is a flex row with justify-content: space-between, so however many pieces are present get spread across the width. Everything else comes from five nth-child cycles of different lengths: color repeats every 8 pieces, shape every 5, fall time every 7, start offset every 11, and a small horizontal nudge every 3. Those lengths share no common factor, so a piece only repeats another piece exactly when all five line up again, which is 9240 pieces away. The @keyframes animation then moves every piece with translateY, adding rotation and a horizontal scale flip for the tumble. One catch worth knowing: a timing function applies to every keyframe segment separately, not to the animation as a whole, so ease-in on a multi-stop fall restarts its curve at each stop and the piece visibly stalls mid-air. The timing here stays linear and the acceleration lives in the distances, which sit on a t² curve.
CSS has no working random function. One is being specified, but it is not something to build on yet, and the usual answer is to write a rule per piece, which is where this effect earns its reputation for being tedious. The way out is to stop describing pieces and start describing cycles. Five nth-child cycles of lengths 8, 5, 7, 11 and 3 run over the same elements, each one handling a single property, and because no two of those lengths share a factor the combinations do not repeat until 9240 pieces have gone by. Thirty-two pieces on screen therefore have thirty-two distinct combinations, from thirty-four short rules rather than thirty-two long ones. The real payoff is what happens next: adding pieces costs nothing at all, because no rule names a piece number.
Every piece is animated with transform and opacity only, which is what keeps the effect cheap. Both properties can be handled by the compositor without touching layout or paint, so thirty-two elements moving at once cost roughly what one does. Animating top instead would force a layout pass on every frame for every piece, and the difference shows up as dropped frames on a laptop that is already busy.
The timing function is the trap this effect is famous for. A timing function applies to each keyframe segment separately, not to the animation as a whole, so ease-in on a fall with four stops restarts its curve at every stop and the piece visibly hesitates four times on the way down. The fix is to keep the timing linear and put the acceleration in the numbers: the four stops here are at 14, 58, 129 and 230 pixels, which is 230 multiplied by the square of the elapsed fraction, the same shape gravity produces.
scaleX(0) at the quarter points is what fakes a tumble. A flat rectangle seen edge on has no width, so squashing the piece to zero and back out to -1 reads as it turning over, and the negative value on the far side is what makes the second half of the turn show the back of the piece. Combined with a full rotate(360deg) across the fall, one piece appears to spin on two axes while only ever being a two dimensional box.
Every delay here is negative, and that is what stops the stage sitting empty for the first second. animation-delay: -1.4s starts a piece 1.4 seconds into its own cycle instead of waiting 1.4 seconds to begin, so the first painted frame already has confetti spread from the top of the stage to the bottom. Each piece is offset by a different fraction of its own duration rather than by a fixed step, which scatters them instead of lining them up on a diagonal. It costs nothing and it removes the moment at the start where the effect has visibly not begun yet.
CSS properties used
transformtranslateYmoves the piece,rotatespins it,scaleXflips it edge on. All three run on the compositor, which is why thirty-two pieces cost about what one does.animation-delay- Negative on every piece here. A negative value starts a piece part way through its own cycle, so the stage is full on the first frame rather than filling up over the first second.
animation-duration- Set by a cycle of seven rather than per piece. Equal durations make any number of pieces read as one falling curtain.
:nth-child()- Five cycles of different lengths, one per property. Because their lengths share no common factor the combinations run for 9240 pieces before repeating, and adding pieces needs no new rule.
overflowhiddenon the stage clips the pieces at its edges, so they appear from above the frame and disappear below it.opacity- Faded to zero in the last keyframe so pieces vanish rather than being cut off by the clip.
Browser support
| Feature | Chrome | Firefox | Safari | Edge |
|---|---|---|---|---|
animation | 4 | 5 | 5.1 | 12 |
transform | 4 | 3.5 | 3.1 | 12 |
:nth-child() | 4 | 3.5 | 3.2 | 12 |
custom properties | 49 | 31 | 10 | 16 |
prefers-reduced-motion | 74 | 63 | 10.1 | 79 |
:nth-child() is tracked on Can I Use inside the CSS3 selectors entry, which is where its figures come from. Custom properties are in the table because the radial variant stores each piece's destination in one, and they are the newest thing on the page by a wide margin. Nothing here needs a fallback: a browser without animations shows thirty-two small colored squares sitting still, which is untidy rather than broken, and a stage with overflow: hidden keeps even that contained.
Accessibility notes
Confetti is decorative and it should be invisible to assistive technology. Give the stage aria-hidden="true" so a screen reader does not announce thirty-two empty elements, and keep the message itself outside that container so it is still read. A celebration that announces nothing but silence is worse than one with no animation at all.
Continuous falling motion in the corner of a page is exactly what prefers-reduced-motion exists for. The right response is animation: none on the pieces, which leaves the message and removes the movement. Hiding the pieces entirely with display: none is also reasonable here, because unlike a glow or a color, static confetti communicates nothing.
A burst is an event, not a state, so an infinite loop is the wrong default in most real uses. The first variant is a cannon fired by a control, which is both the honest interaction model and the version that stops moving on its own. Where a loop is genuinely wanted, keep it short lived or fade it out.
What you can build with it
- Checkout and signup confirmations. One shot from a cannon when an order completes, aimed at the part of the screen the confirmation is on, which is the first variant below.
- Course and onboarding completion. A short celebration on the last step of a flow, alongside the message that says what was finished.
- Achievement and streak badges. A radial burst behind a badge at the moment it is awarded, which is the second variant.
- Seasonal decoration. A slow, sparse fall behind a header for a launch or a holiday, at a low opacity so it does not compete with the text.
- Empty state rewards. A small burst when a to do list is cleared, paired with something like the typewriter effect spelling out the message.
Mistakes worth avoiding
- Writing the values inside each cycle in order. The cycles then march in step for the first few pieces and the scatter opens with a visible gradient, which is worse than the regularity it was meant to hide. Shuffle the values inside each cycle and the phase problem goes away.
- Using a non linear timing function across a multi stop fall. Each segment restarts the curve, so the piece stalls once per keyframe on the way down.
- Animating
toprather thantransform. Every frame forces a layout pass for every piece, and thirty-two of those is enough to drop frames. - Giving all the pieces the same duration and delay. They fall as one solid curtain, which reads as a screen wipe rather than confetti. Leaving every delay positive has a smaller version of the same problem: the stage is empty on arrival and fills up over the first second.
- Leaving
overflow: hiddenoff the stage. Pieces keep falling past the container and land on whatever is below it in the page. - Looping forever with no reduced motion escape. Perpetual movement in the corner of the screen is a genuine accessibility problem, not a stylistic one.
Frequently asked questions
How do you make confetti with CSS only?
<div> elements inside a container with overflow: hidden, animate them downward with transform: translateY plus a rotation, and use nth-child to give each one its own column, color, duration and delay. No script and no canvas is involved.Why does my confetti stutter or hesitate as it falls?
linear and put the acceleration into the distances instead, roughly proportional to the square of the elapsed time.How do you trigger a CSS animation on click?
none at rest and applied under a :checked ~ rule, so activating the label starts it. A single checkbox latches, though, so the second click stops the shot instead of repeating it. Two radios fix that: two labels stacked in one place, one shown at a time, each pointing at the radio that is not checked. Every click switches the animation-name between two blocks describing the same motion, and a changed name restarts it.How do you randomize CSS animations without a random function?
nth-child cycles of different lengths, one per property, and make sure no two lengths share a factor. Cycles of 8, 5, 7, 11 and 3 give combinations that do not repeat for 9240 elements, which is indistinguishable from random at any size you would actually render. Shuffle the values inside each cycle as well, otherwise the cycles start in step and the first several elements read as a gradient.Can the cannon fire in a different direction?
nth-child(even) gets -1 and the blast splits both ways.How many confetti pieces is too many?
Can confetti burst outward instead of falling?
transform: translate() out to a different destination per piece, stored in a custom property so one keyframe block serves all of them. The second variant below does this with twelve pieces spaced thirty degrees apart.