9 CSS Particle Backgrounds — One Line, Copy-Paste
A css particle background scatters small shapes across a stage and drifts each one along its own nth-child path with transform and opacity alone, no JavaScript
01 particle-background-snow-fall
02 particle-background-stars-twinkle
03 particle-background-wave-svg
04 particle-background-rain-lines
05 particle-background-confetti-css
06 particle-background-bubbles-rise
07 particle-background-fireflies
08 particle-background-sakura-petals
09 particle-background-fog-drift
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 Falling snow
- 02 Twinkling stars
- 03 SVG wave
- 04 Falling rain
- 05 Falling confetti
- 06 Rising bubbles
- 07 Fireflies
- 08 Falling sakura petals
- 09 Drifting fog
The nine below are ordered by how their particles move, not by how flashy they look. The css falling snowflakes in 01, along with 04, 05, and 08, fall from above the stage. 02 and 03 hold their ground or slide sideways instead of falling. 06 climbs from the bottom up, and 07 wanders on a short, folding loop instead of committing to one direction. 09 closes the set as the odd one out — three wide bands sweep back and forth together instead of dozens of separate particles. Each code block below keeps only the rule that decides how many particles exist and how they're scattered; the complete file for all nine ships in the zip. When reaction matters more to you than direction, the CSS 3D cube carousel tilt set covers an axis that answers the cursor, and if it's a single button rather than a whole backdrop you need to move, the Tailwind v4 animation utilities are the shorter route.
01Falling snow
What a css snow effect background needs on first paint
24 small circles start above the frame, and nth-child hands each one a different left position, size, and fall duration before translateY and opacity carry it down. A single --i custom property multiplied by a negative offset staggers the delay, so all 24 loops are already mid-fall on first paint instead of starting stacked together.

<main class="stage">
<div class="field" aria-hidden="true">
<span class="p" style="--i:0"></span>
<span class="p" style="--i:1"></span>
<span class="p" style="--i:2"></span>
<span class="p" style="--i:3"></span>
<span class="p" style="--i:4"></span>
<span class="p" style="--i:5"></span>
<span class="p" style="--i:6"></span>
<span class="p" style="--i:7"></span>
<span class="p" style="--i:8"></span>
<span class="p" style="--i:9"></span>
<span class="p" style="--i:10"></span>
<span class="p" style="--i:11"></span>
<span class="p" style="--i:12"></span>
<span class="p" style="--i:13"></span>
<span class="p" style="--i:14"></span>
<span class="p" style="--i:15"></span>
<span class="p" style="--i:16"></span>
<span class="p" style="--i:17"></span>
<span class="p" style="--i:18"></span>
<span class="p" style="--i:19"></span>
<span class="p" style="--i:20"></span>
<span class="p" style="--i:21"></span>
<span class="p" style="--i:22"></span>
<span class="p" style="--i:23"></span>
</div>
<p class="caption">Year-End Sale</p>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────
$duration: $dur-loop * 3; // 6s — 눈송이가 위에서 아래까지 떨어지는 기준 시간
$easing: linear; // 일정한 속도로 낙하 (spring 은 눈에 안 맞는다)
$color: $subject-cream;
// ── 데모 무대 ───────────────────────────────
@include stage($stage-ink);
.field { position: absolute; inset: 0; overflow: hidden; pointer-events: none; }
.caption {
position: relative; z-index: 1; margin: 0;
color: $subject-cream; font-weight: 700; font-size: clamp(16px, 5vw, 22px); line-height: 1; font-family: inherit;
text-shadow: 0 2px 8px rgba($stage-ink, .6);
}
.p {
position: absolute;
top: -8%;
left: 0;
width: 6px; height: 6px;
border-radius: 50%;
background: $color;
opacity: 0;
animation: fall $duration $easing infinite;
animation-delay: calc(var(--i) * -0.28s); // 음수 지연 — 처음부터 이미 흩어져 떨어지고 있는 것처럼
}
// nth-child 로 항목마다 다른 가로 위치·크기·속도 (SCSS 컴파일 시 고정, --i 는 지연에만 쓴다)
@for $i from 1 through 24 {
.p:nth-child(#{$i}) {
left: ((($i * 53) % 97)) * 1%;
$s: 4px + ($i % 4) * 2px;
width: $s; height: $s;
animation-duration: $duration + ($i % 5) * .6s;
}
}
@keyframes fall {
0% { transform: translateY(0) translateX(0); opacity: 0; }
8% { opacity: .9; }
92% { opacity: .85; }
100% { transform: translateY(340px) translateX(14px); opacity: 0; }
}
// ── 접근성: 움직임 줄이기 설정이면 제자리에 흐릿하게만 ────────────
@media (prefers-reduced-motion: reduce) {
.p { animation: none; opacity: .35; top: 40%; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.field {
position: absolute;
inset: 0;
overflow: hidden;
pointer-events: none;
}
.caption {
position: relative;
z-index: 1;
margin: 0;
color: #fff7e6;
font-weight: 700;
font-size: clamp(16px, 5vw, 22px);
line-height: 1;
font-family: inherit;
text-shadow: 0 2px 8px rgba(23, 20, 26, 0.6);
}
.p {
position: absolute;
top: -8%;
left: 0;
width: 6px;
height: 6px;
border-radius: 50%;
background: #fff7e6;
opacity: 0;
animation: fall 6s linear infinite;
animation-delay: calc(var(--i) * -0.28s);
}
.p:nth-child(1) {
left: 53%;
width: 6px;
height: 6px;
animation-duration: 6.6s;
}
.p:nth-child(2) {
left: 9%;
width: 8px;
height: 8px;
animation-duration: 7.2s;
}
.p:nth-child(3) {
left: 62%;
width: 10px;
height: 10px;
animation-duration: 7.8s;
}
.p:nth-child(4) {
left: 18%;
width: 4px;
height: 4px;
animation-duration: 8.4s;
}
.p:nth-child(5) {
left: 71%;
width: 6px;
height: 6px;
animation-duration: 6s;
}
.p:nth-child(6) {
left: 27%;
width: 8px;
height: 8px;
animation-duration: 6.6s;
}
.p:nth-child(7) {
left: 80%;
width: 10px;
height: 10px;
animation-duration: 7.2s;
}
.p:nth-child(8) {
left: 36%;
width: 4px;
height: 4px;
animation-duration: 7.8s;
}
.p:nth-child(9) {
left: 89%;
width: 6px;
height: 6px;
animation-duration: 8.4s;
}
.p:nth-child(10) {
left: 45%;
width: 8px;
height: 8px;
animation-duration: 6s;
}
.p:nth-child(11) {
left: 1%;
width: 10px;
height: 10px;
animation-duration: 6.6s;
}
.p:nth-child(12) {
left: 54%;
width: 4px;
height: 4px;
animation-duration: 7.2s;
}
.p:nth-child(13) {
left: 10%;
width: 6px;
height: 6px;
animation-duration: 7.8s;
}
.p:nth-child(14) {
left: 63%;
width: 8px;
height: 8px;
animation-duration: 8.4s;
}
.p:nth-child(15) {
left: 19%;
width: 10px;
height: 10px;
animation-duration: 6s;
}
.p:nth-child(16) {
left: 72%;
width: 4px;
height: 4px;
animation-duration: 6.6s;
}
.p:nth-child(17) {
left: 28%;
width: 6px;
height: 6px;
animation-duration: 7.2s;
}
.p:nth-child(18) {
left: 81%;
width: 8px;
height: 8px;
animation-duration: 7.8s;
}
.p:nth-child(19) {
left: 37%;
width: 10px;
height: 10px;
animation-duration: 8.4s;
}
.p:nth-child(20) {
left: 90%;
width: 4px;
height: 4px;
animation-duration: 6s;
}
.p:nth-child(21) {
left: 46%;
width: 6px;
height: 6px;
animation-duration: 6.6s;
}
.p:nth-child(22) {
left: 2%;
width: 8px;
height: 8px;
animation-duration: 7.2s;
}
.p:nth-child(23) {
left: 55%;
width: 10px;
height: 10px;
animation-duration: 7.8s;
}
.p:nth-child(24) {
left: 11%;
width: 4px;
height: 4px;
animation-duration: 8.4s;
}
@keyframes fall {
0% {
transform: translateY(0) translateX(0);
opacity: 0;
}
8% {
opacity: 0.9;
}
92% {
opacity: 0.85;
}
100% {
transform: translateY(340px) translateX(14px);
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.p {
animation: none;
opacity: 0.35;
top: 40%;
}
}import type { CSSProperties } from "react";
import styles from "./SnowFall.module.scss";
export type SnowFallProps = {
/** 애니메이션 기준 시간(ms) */
duration?: number;
/** CSS easing */
easing?: string;
/** 피사체 색 */
color?: string;
};
type Vars = CSSProperties & { "--i"?: number };
const COUNT = 24;
export default function SnowFall({
duration = 6000,
easing = "linear",
color = "#fff7e6",
}: SnowFallProps) {
const vars = {
"--duration": `${duration}ms`,
"--easing": easing,
"--color": color,
} as CSSProperties;
return (
<div className={styles.field} style={vars} aria-hidden="true">
{Array.from({ length: COUNT }, (_, i) => (
<span key={i} className={styles.p} style={{ "--i": i } as Vars} />
))}
</div>
);
}01 · Falling snow — Measured area 11.553% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where a winter hero fits
It fits a winter promo hero or the top of a year-end landing page.
.p {
position: absolute; top: -8%; left: 0;
width: 6px; height: 6px; border-radius: 50%;
animation: fall 6s linear infinite;
animation-delay: calc(var(--i) * -0.28s);
}
@for $i from 1 through 24 {
.p:nth-child(#{$i}) { left: #{($i * 53) % 97}%; }
}
@keyframes fall {
0% { transform: translateY(0); opacity: 0; }
100% { transform: translateY(340px); opacity: 0; }
}
02Twinkling stars
24 dots, 24 schedules
24 dots sit scattered across the stage, each blinking between 0.2 and 1 opacity on its own schedule rather than all 24 sharing one visible clock. A glowing box-shadow keeps every dot reading as a point of light instead of a flat disc.

<main class="stage">
<div class="field" aria-hidden="true">
<span class="p" style="--i:0"></span>
<span class="p" style="--i:1"></span>
<span class="p" style="--i:2"></span>
<span class="p" style="--i:3"></span>
<span class="p" style="--i:4"></span>
<span class="p" style="--i:5"></span>
<span class="p" style="--i:6"></span>
<span class="p" style="--i:7"></span>
<span class="p" style="--i:8"></span>
<span class="p" style="--i:9"></span>
<span class="p" style="--i:10"></span>
<span class="p" style="--i:11"></span>
<span class="p" style="--i:12"></span>
<span class="p" style="--i:13"></span>
<span class="p" style="--i:14"></span>
<span class="p" style="--i:15"></span>
<span class="p" style="--i:16"></span>
<span class="p" style="--i:17"></span>
<span class="p" style="--i:18"></span>
<span class="p" style="--i:19"></span>
<span class="p" style="--i:20"></span>
<span class="p" style="--i:21"></span>
<span class="p" style="--i:22"></span>
<span class="p" style="--i:23"></span>
</div>
<p class="caption">Tonight Only</p>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────
$duration: $dur-base * 6; // 1.8s 기준 — 항목마다 다르게 늘려 불규칙해 보이게 한다
$easing: ease-in-out;
$color: $subject-ink;
// ── 데모 무대 ───────────────────────────────
@include stage($stage-yellow);
.field { position: absolute; inset: 0; overflow: hidden; pointer-events: none; }
.caption {
position: relative; z-index: 1; margin: 0;
color: $subject-ink; font-weight: 700; font-size: clamp(16px, 5vw, 22px); line-height: 1; font-family: inherit;
}
.p {
position: absolute;
width: 5px; height: 5px;
border-radius: 50%;
background: $color;
box-shadow: 0 0 10px rgba($color, .55);
opacity: .2;
animation: twinkle $duration $easing infinite;
animation-delay: calc(var(--i) * -0.31s);
}
@for $i from 1 through 24 {
.p:nth-child(#{$i}) {
left: ((($i * 41) % 93)) * 1%;
top: ((($i * 67) % 80)) * 1%;
animation-duration: $duration + ($i % 5) * .35s;
}
}
@keyframes twinkle {
0%, 100% { transform: scale(.7); opacity: .2; }
50% { transform: scale(1.3); opacity: 1; }
}
// ── 접근성: 반짝임을 끄고 희미한 별만 남긴다 ────────────
@media (prefers-reduced-motion: reduce) {
.p { animation: none; opacity: .55; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.field {
position: absolute;
inset: 0;
overflow: hidden;
pointer-events: none;
}
.caption {
position: relative;
z-index: 1;
margin: 0;
color: #17141a;
font-weight: 700;
font-size: clamp(16px, 5vw, 22px);
line-height: 1;
font-family: inherit;
}
.p {
position: absolute;
width: 5px;
height: 5px;
border-radius: 50%;
background: #17141a;
box-shadow: 0 0 10px rgba(23, 20, 26, 0.55);
opacity: 0.2;
animation: twinkle 1800ms ease-in-out infinite;
animation-delay: calc(var(--i) * -0.31s);
}
.p:nth-child(1) {
left: 41%;
top: 67%;
animation-duration: 2150ms;
}
.p:nth-child(2) {
left: 82%;
top: 54%;
animation-duration: 2500ms;
}
.p:nth-child(3) {
left: 30%;
top: 41%;
animation-duration: 2850ms;
}
.p:nth-child(4) {
left: 71%;
top: 28%;
animation-duration: 3200ms;
}
.p:nth-child(5) {
left: 19%;
top: 15%;
animation-duration: 1800ms;
}
.p:nth-child(6) {
left: 60%;
top: 2%;
animation-duration: 2150ms;
}
.p:nth-child(7) {
left: 8%;
top: 69%;
animation-duration: 2500ms;
}
.p:nth-child(8) {
left: 49%;
top: 56%;
animation-duration: 2850ms;
}
.p:nth-child(9) {
left: 90%;
top: 43%;
animation-duration: 3200ms;
}
.p:nth-child(10) {
left: 38%;
top: 30%;
animation-duration: 1800ms;
}
.p:nth-child(11) {
left: 79%;
top: 17%;
animation-duration: 2150ms;
}
.p:nth-child(12) {
left: 27%;
top: 4%;
animation-duration: 2500ms;
}
.p:nth-child(13) {
left: 68%;
top: 71%;
animation-duration: 2850ms;
}
.p:nth-child(14) {
left: 16%;
top: 58%;
animation-duration: 3200ms;
}
.p:nth-child(15) {
left: 57%;
top: 45%;
animation-duration: 1800ms;
}
.p:nth-child(16) {
left: 5%;
top: 32%;
animation-duration: 2150ms;
}
.p:nth-child(17) {
left: 46%;
top: 19%;
animation-duration: 2500ms;
}
.p:nth-child(18) {
left: 87%;
top: 6%;
animation-duration: 2850ms;
}
.p:nth-child(19) {
left: 35%;
top: 73%;
animation-duration: 3200ms;
}
.p:nth-child(20) {
left: 76%;
top: 60%;
animation-duration: 1800ms;
}
.p:nth-child(21) {
left: 24%;
top: 47%;
animation-duration: 2150ms;
}
.p:nth-child(22) {
left: 65%;
top: 34%;
animation-duration: 2500ms;
}
.p:nth-child(23) {
left: 13%;
top: 21%;
animation-duration: 2850ms;
}
.p:nth-child(24) {
left: 54%;
top: 8%;
animation-duration: 3200ms;
}
@keyframes twinkle {
0%, 100% {
transform: scale(0.7);
opacity: 0.2;
}
50% {
transform: scale(1.3);
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.p {
animation: none;
opacity: 0.55;
}
}import type { CSSProperties } from "react";
import styles from "./StarsTwinkle.module.scss";
export type StarsTwinkleProps = {
/** 애니메이션 기준 시간(ms) */
duration?: number;
/** CSS easing */
easing?: string;
/** 피사체 색 */
color?: string;
};
type Vars = CSSProperties & { "--i"?: number };
const COUNT = 24;
export default function StarsTwinkle({
duration = 1800,
easing = "ease-in-out",
color = "#17141a",
}: StarsTwinkleProps) {
const vars = {
"--duration": `${duration}ms`,
"--easing": easing,
"--color": color,
} as CSSProperties;
return (
<div className={styles.field} style={vars} aria-hidden="true">
{Array.from({ length: COUNT }, (_, i) => (
<span key={i} className={styles.p} style={{ "--i": i } as Vars} />
))}
</div>
);
}02 · Twinkling stars — Measured area 0.894% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
Behind a dark-mode hero
It works well behind a dark-mode landing hero or a night-themed event page.
.p {
position: absolute; width: 5px; height: 5px; border-radius: 50%;
box-shadow: 0 0 10px rgba(23, 20, 26, .55);
animation: twinkle 1.8s ease-in-out infinite;
animation-delay: calc(var(--i) * -0.31s);
}
@keyframes twinkle {
0%, 100% { transform: scale(.7); opacity: .2; }
50% { transform: scale(1.3); opacity: 1; }
}
03SVG wave
The same artwork meeting itself
The same wave path is drawn twice, side by side, inside a layer set to 200% width, and translateX slides that whole layer by exactly half its own width on a loop. Because the seam between the two copies is the same artwork meeting itself, the restart never shows.

<main class="stage">
<div class="waves" aria-hidden="true">
<div class="layer layer--back">
<svg class="wave" viewBox="0 0 480 120" preserveAspectRatio="none" aria-hidden="true"><path d="M0,60 C60,90 120,30 180,60 C240,90 300,30 360,60 C420,90 480,30 480,60 L480,120 L0,120 Z"/></svg>
<svg class="wave" viewBox="0 0 480 120" preserveAspectRatio="none" aria-hidden="true"><path d="M0,60 C60,90 120,30 180,60 C240,90 300,30 360,60 C420,90 480,30 480,60 L480,120 L0,120 Z"/></svg>
</div>
<div class="layer layer--front">
<svg class="wave" viewBox="0 0 480 120" preserveAspectRatio="none" aria-hidden="true"><path d="M0,70 C50,40 130,100 180,70 C240,40 300,100 360,70 C420,40 480,100 480,70 L480,120 L0,120 Z"/></svg>
<svg class="wave" viewBox="0 0 480 120" preserveAspectRatio="none" aria-hidden="true"><path d="M0,70 C50,40 130,100 180,70 C240,40 300,100 360,70 C420,40 480,100 480,70 L480,120 L0,120 Z"/></svg>
</div>
</div>
<p class="caption">Summer Arrivals</p>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────
$duration: $dur-loop * 3; // 6s — 뒤 레이어 한 바퀴
$easing: linear;
$color: $subject-cream;
// ── 데모 무대 ───────────────────────────────
@include stage($stage-orange);
.caption {
position: absolute; z-index: 1; top: $sp-6; left: $sp-6; margin: 0;
color: $subject-cream; font-weight: 700; font-size: clamp(16px, 5vw, 22px); line-height: 1; font-family: inherit;
}
// .layer 는 폭 200%로 뷰포트 밖까지 나간다 — 문서 스크롤을 부풀리지 않도록 .waves 로 먼저 가둔다 (BD8)
.waves { position: absolute; inset: 0; overflow: hidden; }
// 물결은 같은 SVG 를 두 장 나란히(각 50%) 붙여 폭 200%로 만들고, 정확히 -50% 만큼 옮기면
// 이어붙인 자리가 서로 같은 그림이라 이음새 없이 반복된다 (마퀴와 같은 원리, flex gap 은 안 쓴다)
.layer {
position: absolute; left: 0; right: 0; bottom: 0; height: 55%;
display: flex; width: 200%;
}
.wave { width: 50%; height: 100%; flex: none; display: block; fill: $color; }
.layer--back {
opacity: .4;
animation: drift $duration $easing infinite;
}
.layer--front {
opacity: .85;
animation: drift ($duration * .65) $easing infinite;
}
@keyframes drift {
from { transform: translateX(0); }
to { transform: translateX(-50%); }
}
// ── 접근성: 물결을 멈추고 정지 이미지로 ────────────
@media (prefers-reduced-motion: reduce) {
.layer { animation: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.caption {
position: absolute;
z-index: 1;
top: 24px;
left: 24px;
margin: 0;
color: #fff7e6;
font-weight: 700;
font-size: clamp(16px, 5vw, 22px);
line-height: 1;
font-family: inherit;
}
.waves {
position: absolute;
inset: 0;
overflow: hidden;
}
.layer {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 55%;
display: flex;
width: 200%;
}
.wave {
width: 50%;
height: 100%;
flex: none;
display: block;
fill: #fff7e6;
}
.layer--back {
opacity: 0.4;
animation: drift 6s linear infinite;
}
.layer--front {
opacity: 0.85;
animation: drift 3.9s linear infinite;
}
@keyframes drift {
from {
transform: translateX(0);
}
to {
transform: translateX(-50%);
}
}
@media (prefers-reduced-motion: reduce) {
.layer {
animation: none;
}
}import type { CSSProperties } from "react";
import styles from "./SvgWave.module.scss";
export type SvgWaveProps = {
/** 애니메이션 기준 시간(ms) */
duration?: number;
/** CSS easing */
easing?: string;
/** 피사체 색 */
color?: string;
};
type Vars = CSSProperties & { "--i"?: number };
const COUNT = 4;
export default function SvgWave({
duration = 6000,
easing = "linear",
color = "#fff7e6",
}: SvgWaveProps) {
const vars = {
"--duration": `${duration}ms`,
"--easing": easing,
"--color": color,
} as CSSProperties;
return (
<div className={styles.stage} style={vars}>
<div className={styles.layerBack}>
<svg className={styles.wave} viewBox="0 0 480 120" preserveAspectRatio="none" aria-hidden="true"><path d="M0,60 C60,90 120,30 180,60 C240,90 300,30 360,60 C420,90 480,30 480,60 L480,120 L0,120 Z" /></svg>
<svg className={styles.wave} viewBox="0 0 480 120" preserveAspectRatio="none" aria-hidden="true"><path d="M0,60 C60,90 120,30 180,60 C240,90 300,30 360,60 C420,90 480,30 480,60 L480,120 L0,120 Z" /></svg>
</div>
<div className={styles.layerFront}>
<svg className={styles.wave} viewBox="0 0 480 120" preserveAspectRatio="none" aria-hidden="true"><path d="M0,70 C50,40 130,100 180,70 C240,40 300,100 360,70 C420,40 480,100 480,70 L480,120 L0,120 Z" /></svg>
<svg className={styles.wave} viewBox="0 0 480 120" preserveAspectRatio="none" aria-hidden="true"><path d="M0,70 C50,40 130,100 180,70 C240,40 300,100 360,70 C420,40 480,100 480,70 L480,120 L0,120 Z" /></svg>
</div>
</div>
);
}03 · SVG wave — Measured area 12.781% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
A footer divider or a travel backdrop
It suits a footer divider under a product section or the backdrop of a travel landing page.
.layer { position: absolute; left: 0; right: 0; bottom: 0; display: flex; width: 200%; }
.wave { width: 50%; flex: none; }
.layer--back { animation: drift 6s linear infinite; }
.layer--front { animation: drift 3.9s linear infinite; }
@keyframes drift {
from { transform: translateX(0); }
to { transform: translateX(-50%); }
}
04Falling rain
22 lines tilted 12 degrees
22 thin lines start tilted 12 degrees and fall diagonally in well under a second, with animation-delay staggering each line's start. The result reads as a bundle of streaks rather than one line repeating in sync.

<main class="stage">
<div class="field" aria-hidden="true">
<span class="p" style="--i:0"></span>
<span class="p" style="--i:1"></span>
<span class="p" style="--i:2"></span>
<span class="p" style="--i:3"></span>
<span class="p" style="--i:4"></span>
<span class="p" style="--i:5"></span>
<span class="p" style="--i:6"></span>
<span class="p" style="--i:7"></span>
<span class="p" style="--i:8"></span>
<span class="p" style="--i:9"></span>
<span class="p" style="--i:10"></span>
<span class="p" style="--i:11"></span>
<span class="p" style="--i:12"></span>
<span class="p" style="--i:13"></span>
<span class="p" style="--i:14"></span>
<span class="p" style="--i:15"></span>
<span class="p" style="--i:16"></span>
<span class="p" style="--i:17"></span>
<span class="p" style="--i:18"></span>
<span class="p" style="--i:19"></span>
<span class="p" style="--i:20"></span>
<span class="p" style="--i:21"></span>
</div>
<p class="caption">Rainy Season Sale</p>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────
$duration: $dur-base * 2; // .6s 기준 — 빗줄기 한 번 떨어지는 시간
$easing: linear;
$color: $subject-blue;
// ── 데모 무대 ───────────────────────────────
@include stage($stage-paper);
.field { position: absolute; inset: 0; overflow: hidden; pointer-events: none; }
.caption {
position: relative; z-index: 1; margin: 0;
color: $subject-ink; font-weight: 700; font-size: clamp(16px, 5vw, 22px); line-height: 1; font-family: inherit;
}
.p {
position: absolute;
top: -12%;
width: 2px; height: 22px;
border-radius: $r-xs;
background: linear-gradient(rgba($color, 0), $color);
opacity: .7;
transform: rotate(12deg);
animation: rain $duration $easing infinite;
animation-delay: calc(var(--i) * -0.07s);
}
@for $i from 1 through 22 {
.p:nth-child(#{$i}) {
left: ((($i * 37) % 96)) * 1%;
animation-duration: $duration + ($i % 4) * .1s;
}
}
@keyframes rain {
0% { transform: translateY(0) translateX(0) rotate(12deg); opacity: .7; }
100% { transform: translateY(380px) translateX(60px) rotate(12deg); opacity: .3; }
}
// ── 접근성: 대각선 낙하를 멈추고 흐린 선만 남긴다 ────────────
@media (prefers-reduced-motion: reduce) {
.p { animation: none; opacity: .25; top: 45%; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.field {
position: absolute;
inset: 0;
overflow: hidden;
pointer-events: none;
}
.caption {
position: relative;
z-index: 1;
margin: 0;
color: #17141a;
font-weight: 700;
font-size: clamp(16px, 5vw, 22px);
line-height: 1;
font-family: inherit;
}
.p {
position: absolute;
top: -12%;
width: 2px;
height: 22px;
border-radius: 4px;
background: linear-gradient(rgba(47, 109, 246, 0), #2f6df6);
opacity: 0.7;
transform: rotate(12deg);
animation: rain 600ms linear infinite;
animation-delay: calc(var(--i) * -0.07s);
}
.p:nth-child(1) {
left: 37%;
animation-duration: 700ms;
}
.p:nth-child(2) {
left: 74%;
animation-duration: 800ms;
}
.p:nth-child(3) {
left: 15%;
animation-duration: 900ms;
}
.p:nth-child(4) {
left: 52%;
animation-duration: 600ms;
}
.p:nth-child(5) {
left: 89%;
animation-duration: 700ms;
}
.p:nth-child(6) {
left: 30%;
animation-duration: 800ms;
}
.p:nth-child(7) {
left: 67%;
animation-duration: 900ms;
}
.p:nth-child(8) {
left: 8%;
animation-duration: 600ms;
}
.p:nth-child(9) {
left: 45%;
animation-duration: 700ms;
}
.p:nth-child(10) {
left: 82%;
animation-duration: 800ms;
}
.p:nth-child(11) {
left: 23%;
animation-duration: 900ms;
}
.p:nth-child(12) {
left: 60%;
animation-duration: 600ms;
}
.p:nth-child(13) {
left: 1%;
animation-duration: 700ms;
}
.p:nth-child(14) {
left: 38%;
animation-duration: 800ms;
}
.p:nth-child(15) {
left: 75%;
animation-duration: 900ms;
}
.p:nth-child(16) {
left: 16%;
animation-duration: 600ms;
}
.p:nth-child(17) {
left: 53%;
animation-duration: 700ms;
}
.p:nth-child(18) {
left: 90%;
animation-duration: 800ms;
}
.p:nth-child(19) {
left: 31%;
animation-duration: 900ms;
}
.p:nth-child(20) {
left: 68%;
animation-duration: 600ms;
}
.p:nth-child(21) {
left: 9%;
animation-duration: 700ms;
}
.p:nth-child(22) {
left: 46%;
animation-duration: 800ms;
}
@keyframes rain {
0% {
transform: translateY(0) translateX(0) rotate(12deg);
opacity: 0.7;
}
100% {
transform: translateY(380px) translateX(60px) rotate(12deg);
opacity: 0.3;
}
}
@media (prefers-reduced-motion: reduce) {
.p {
animation: none;
opacity: 0.25;
top: 45%;
}
}import type { CSSProperties } from "react";
import styles from "./RainLines.module.scss";
export type RainLinesProps = {
/** 애니메이션 기준 시간(ms) */
duration?: number;
/** CSS easing */
easing?: string;
/** 피사체 색 */
color?: string;
};
type Vars = CSSProperties & { "--i"?: number };
const COUNT = 22;
export default function RainLines({
duration = 600,
easing = "linear",
color = "#2f6df6",
}: RainLinesProps) {
const vars = {
"--duration": `${duration}ms`,
"--easing": easing,
"--color": color,
} as CSSProperties;
return (
<div className={styles.field} style={vars} aria-hidden="true">
{Array.from({ length: COUNT }, (_, i) => (
<span key={i} className={styles.p} style={{ "--i": i } as Vars} />
))}
</div>
);
}04 · Falling rain — Measured area 12.851% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
A rainy-season promo
It fits a rainy-season promo background or the intro screen of a weather app.
.p {
position: absolute; top: -12%; width: 2px; height: 22px;
transform: rotate(12deg);
animation: rain .6s linear infinite;
animation-delay: calc(var(--i) * -0.07s);
}
@keyframes rain {
0% { transform: translateY(0) translateX(0) rotate(12deg); opacity: .7; }
100% { transform: translateY(380px) translateX(60px) rotate(12deg); opacity: .3; }
}
05Falling confetti
Two rotation signs, one loop
26 small rectangles fall while spinning, and a second @for loop hands alternating pieces an opposite rotation sign, so roughly half turn clockwise and half turn counterclockwise. Every third rectangle switches to a second color instead of the whole batch sharing one.

<main class="stage">
<div class="field" aria-hidden="true">
<span class="p" style="--i:0"></span>
<span class="p" style="--i:1"></span>
<span class="p" style="--i:2"></span>
<span class="p" style="--i:3"></span>
<span class="p" style="--i:4"></span>
<span class="p" style="--i:5"></span>
<span class="p" style="--i:6"></span>
<span class="p" style="--i:7"></span>
<span class="p" style="--i:8"></span>
<span class="p" style="--i:9"></span>
<span class="p" style="--i:10"></span>
<span class="p" style="--i:11"></span>
<span class="p" style="--i:12"></span>
<span class="p" style="--i:13"></span>
<span class="p" style="--i:14"></span>
<span class="p" style="--i:15"></span>
<span class="p" style="--i:16"></span>
<span class="p" style="--i:17"></span>
<span class="p" style="--i:18"></span>
<span class="p" style="--i:19"></span>
<span class="p" style="--i:20"></span>
<span class="p" style="--i:21"></span>
<span class="p" style="--i:22"></span>
<span class="p" style="--i:23"></span>
<span class="p" style="--i:24"></span>
<span class="p" style="--i:25"></span>
</div>
<p class="caption">Welcome Aboard</p>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────
$duration: $dur-enter * 4; // 2.4s 기준
$easing: linear;
$color: $subject-cream;
// ── 데모 무대 ───────────────────────────────
@include stage($stage-ink);
.field { position: absolute; inset: 0; overflow: hidden; pointer-events: none; }
.caption {
position: relative; z-index: 1; margin: 0;
color: $subject-cream; font-weight: 700; font-size: clamp(16px, 5vw, 22px); line-height: 1; font-family: inherit;
}
.p {
position: absolute;
top: -10%;
width: 7px; height: 11px;
background: $color;
opacity: .95;
animation: confetti $duration $easing infinite;
animation-delay: calc(var(--i) * -0.2s);
}
// 3개마다 색을 cream ↔ blue 로 번갈아 섞는다 (팔레트 안에서만)
.p:nth-child(3n) { background: $subject-blue; }
@for $i from 1 through 26 {
.p:nth-child(#{$i}) {
left: ((($i * 31) % 94)) * 1%;
animation-duration: $duration + ($i % 5) * .3s;
}
}
@for $i from 1 through 13 {
.p:nth-child(#{$i * 2}) {
--spin: #{240deg + ($i % 6) * 60deg};
}
.p:nth-child(#{$i * 2 - 1}) {
--spin: #{-1 * (240deg + ($i % 6) * 60deg)};
}
}
@keyframes confetti {
0% { transform: translateY(0) rotate(0deg); opacity: 0; }
8% { opacity: .95; }
100% { transform: translateY(360px) rotate(var(--spin, 360deg)); opacity: .85; }
}
// ── 접근성: 회전·낙하를 멈추고 흐린 조각만 남긴다 ────────────
@media (prefers-reduced-motion: reduce) {
.p { animation: none; opacity: .3; top: 45%; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.field {
position: absolute;
inset: 0;
overflow: hidden;
pointer-events: none;
}
.caption {
position: relative;
z-index: 1;
margin: 0;
color: #fff7e6;
font-weight: 700;
font-size: clamp(16px, 5vw, 22px);
line-height: 1;
font-family: inherit;
}
.p {
position: absolute;
top: -10%;
width: 7px;
height: 11px;
background: #fff7e6;
opacity: 0.95;
animation: confetti 2400ms linear infinite;
animation-delay: calc(var(--i) * -0.2s);
}
.p:nth-child(3n) {
background: #2f6df6;
}
.p:nth-child(1) {
left: 31%;
animation-duration: 2700ms;
}
.p:nth-child(2) {
left: 62%;
animation-duration: 3000ms;
}
.p:nth-child(3) {
left: 93%;
animation-duration: 3300ms;
}
.p:nth-child(4) {
left: 30%;
animation-duration: 3600ms;
}
.p:nth-child(5) {
left: 61%;
animation-duration: 2400ms;
}
.p:nth-child(6) {
left: 92%;
animation-duration: 2700ms;
}
.p:nth-child(7) {
left: 29%;
animation-duration: 3000ms;
}
.p:nth-child(8) {
left: 60%;
animation-duration: 3300ms;
}
.p:nth-child(9) {
left: 91%;
animation-duration: 3600ms;
}
.p:nth-child(10) {
left: 28%;
animation-duration: 2400ms;
}
.p:nth-child(11) {
left: 59%;
animation-duration: 2700ms;
}
.p:nth-child(12) {
left: 90%;
animation-duration: 3000ms;
}
.p:nth-child(13) {
left: 27%;
animation-duration: 3300ms;
}
.p:nth-child(14) {
left: 58%;
animation-duration: 3600ms;
}
.p:nth-child(15) {
left: 89%;
animation-duration: 2400ms;
}
.p:nth-child(16) {
left: 26%;
animation-duration: 2700ms;
}
.p:nth-child(17) {
left: 57%;
animation-duration: 3000ms;
}
.p:nth-child(18) {
left: 88%;
animation-duration: 3300ms;
}
.p:nth-child(19) {
left: 25%;
animation-duration: 3600ms;
}
.p:nth-child(20) {
left: 56%;
animation-duration: 2400ms;
}
.p:nth-child(21) {
left: 87%;
animation-duration: 2700ms;
}
.p:nth-child(22) {
left: 24%;
animation-duration: 3000ms;
}
.p:nth-child(23) {
left: 55%;
animation-duration: 3300ms;
}
.p:nth-child(24) {
left: 86%;
animation-duration: 3600ms;
}
.p:nth-child(25) {
left: 23%;
animation-duration: 2400ms;
}
.p:nth-child(26) {
left: 54%;
animation-duration: 2700ms;
}
.p:nth-child(2) {
--spin: 300deg;
}
.p:nth-child(1) {
--spin: -300deg;
}
.p:nth-child(4) {
--spin: 360deg;
}
.p:nth-child(3) {
--spin: -360deg;
}
.p:nth-child(6) {
--spin: 420deg;
}
.p:nth-child(5) {
--spin: -420deg;
}
.p:nth-child(8) {
--spin: 480deg;
}
.p:nth-child(7) {
--spin: -480deg;
}
.p:nth-child(10) {
--spin: 540deg;
}
.p:nth-child(9) {
--spin: -540deg;
}
.p:nth-child(12) {
--spin: 240deg;
}
.p:nth-child(11) {
--spin: -240deg;
}
.p:nth-child(14) {
--spin: 300deg;
}
.p:nth-child(13) {
--spin: -300deg;
}
.p:nth-child(16) {
--spin: 360deg;
}
.p:nth-child(15) {
--spin: -360deg;
}
.p:nth-child(18) {
--spin: 420deg;
}
.p:nth-child(17) {
--spin: -420deg;
}
.p:nth-child(20) {
--spin: 480deg;
}
.p:nth-child(19) {
--spin: -480deg;
}
.p:nth-child(22) {
--spin: 540deg;
}
.p:nth-child(21) {
--spin: -540deg;
}
.p:nth-child(24) {
--spin: 240deg;
}
.p:nth-child(23) {
--spin: -240deg;
}
.p:nth-child(26) {
--spin: 300deg;
}
.p:nth-child(25) {
--spin: -300deg;
}
@keyframes confetti {
0% {
transform: translateY(0) rotate(0deg);
opacity: 0;
}
8% {
opacity: 0.95;
}
100% {
transform: translateY(360px) rotate(var(--spin, 360deg));
opacity: 0.85;
}
}
@media (prefers-reduced-motion: reduce) {
.p {
animation: none;
opacity: 0.3;
top: 45%;
}
}import type { CSSProperties } from "react";
import styles from "./ConfettiFall.module.scss";
export type ConfettiFallProps = {
/** 애니메이션 기준 시간(ms) */
duration?: number;
/** CSS easing */
easing?: string;
/** 피사체 색 */
color?: string;
};
type Vars = CSSProperties & { "--i"?: number };
const COUNT = 26;
export default function ConfettiFall({
duration = 2400,
easing = "linear",
color = "#fff7e6",
}: ConfettiFallProps) {
const vars = {
"--duration": `${duration}ms`,
"--easing": easing,
"--color": color,
} as CSSProperties;
return (
<div className={styles.field} style={vars} aria-hidden="true">
{Array.from({ length: COUNT }, (_, i) => (
<span key={i} className={styles.p} style={{ "--i": i } as Vars} />
))}
</div>
);
}05 · Falling confetti — Measured area 23.881% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
A payment-success screen
It suits a payment-success screen, a signup celebration, or an event-winner page.
.p { position: absolute; top: -10%; width: 7px; height: 11px; }
.p:nth-child(3n) { background: #2f6df6; }
@keyframes confetti {
0% { transform: translateY(0) rotate(0deg); opacity: 0; }
100% { transform: translateY(360px) rotate(var(--spin, 360deg)); opacity: .85; }
}
06Rising bubbles
Scale growing on the way up
20 circles carry only a border and a faint fill, and translateY lifts each one from the bottom of the stage while scale grows then shrinks on the way up. The eased timing keeps the rise from reading as mechanical, closer to something actually pushing through liquid.

<main class="stage">
<div class="field" aria-hidden="true">
<span class="p" style="--i:0"></span>
<span class="p" style="--i:1"></span>
<span class="p" style="--i:2"></span>
<span class="p" style="--i:3"></span>
<span class="p" style="--i:4"></span>
<span class="p" style="--i:5"></span>
<span class="p" style="--i:6"></span>
<span class="p" style="--i:7"></span>
<span class="p" style="--i:8"></span>
<span class="p" style="--i:9"></span>
<span class="p" style="--i:10"></span>
<span class="p" style="--i:11"></span>
<span class="p" style="--i:12"></span>
<span class="p" style="--i:13"></span>
<span class="p" style="--i:14"></span>
<span class="p" style="--i:15"></span>
<span class="p" style="--i:16"></span>
<span class="p" style="--i:17"></span>
<span class="p" style="--i:18"></span>
<span class="p" style="--i:19"></span>
</div>
<p class="caption">Cool Down This Summer</p>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────
$duration: $dur-loop * 2; // 4s 기준
$easing: $ease-out;
$color: $subject-ink;
// ── 데모 무대 ───────────────────────────────
@include stage($stage-yellow);
.field { position: absolute; inset: 0; overflow: hidden; pointer-events: none; }
.caption {
position: relative; z-index: 1; margin: 0;
color: $subject-ink; font-weight: 700; font-size: clamp(16px, 5vw, 22px); line-height: 1; font-family: inherit;
}
.p {
position: absolute;
bottom: -10%;
width: 10px; height: 10px;
border-radius: 50%;
border: 1.5px solid $color;
background: rgba($color, .1);
opacity: 0;
animation: rise $duration $easing infinite;
animation-delay: calc(var(--i) * -0.24s);
}
@for $i from 1 through 20 {
.p:nth-child(#{$i}) {
left: ((($i * 43) % 92)) * 1%;
$s: 6px + ($i % 5) * 2px;
width: $s; height: $s;
animation-duration: $duration + ($i % 4) * .5s;
}
}
@keyframes rise {
0% { transform: translateY(0) scale(.6); opacity: 0; }
12% { opacity: .8; }
55% { transform: translateY(-170px) scale(1.15); }
100% { transform: translateY(-360px) scale(.8); opacity: 0; }
}
// ── 접근성: 상승을 멈추고 제자리 거품만 남긴다 ────────────
@media (prefers-reduced-motion: reduce) {
.p { animation: none; opacity: .4; bottom: 20%; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.field {
position: absolute;
inset: 0;
overflow: hidden;
pointer-events: none;
}
.caption {
position: relative;
z-index: 1;
margin: 0;
color: #17141a;
font-weight: 700;
font-size: clamp(16px, 5vw, 22px);
line-height: 1;
font-family: inherit;
}
.p {
position: absolute;
bottom: -10%;
width: 10px;
height: 10px;
border-radius: 50%;
border: 1.5px solid #17141a;
background: rgba(23, 20, 26, 0.1);
opacity: 0;
animation: rise 4s ease-out infinite;
animation-delay: calc(var(--i) * -0.24s);
}
.p:nth-child(1) {
left: 43%;
width: 8px;
height: 8px;
animation-duration: 4.5s;
}
.p:nth-child(2) {
left: 86%;
width: 10px;
height: 10px;
animation-duration: 5s;
}
.p:nth-child(3) {
left: 37%;
width: 12px;
height: 12px;
animation-duration: 5.5s;
}
.p:nth-child(4) {
left: 80%;
width: 14px;
height: 14px;
animation-duration: 4s;
}
.p:nth-child(5) {
left: 31%;
width: 6px;
height: 6px;
animation-duration: 4.5s;
}
.p:nth-child(6) {
left: 74%;
width: 8px;
height: 8px;
animation-duration: 5s;
}
.p:nth-child(7) {
left: 25%;
width: 10px;
height: 10px;
animation-duration: 5.5s;
}
.p:nth-child(8) {
left: 68%;
width: 12px;
height: 12px;
animation-duration: 4s;
}
.p:nth-child(9) {
left: 19%;
width: 14px;
height: 14px;
animation-duration: 4.5s;
}
.p:nth-child(10) {
left: 62%;
width: 6px;
height: 6px;
animation-duration: 5s;
}
.p:nth-child(11) {
left: 13%;
width: 8px;
height: 8px;
animation-duration: 5.5s;
}
.p:nth-child(12) {
left: 56%;
width: 10px;
height: 10px;
animation-duration: 4s;
}
.p:nth-child(13) {
left: 7%;
width: 12px;
height: 12px;
animation-duration: 4.5s;
}
.p:nth-child(14) {
left: 50%;
width: 14px;
height: 14px;
animation-duration: 5s;
}
.p:nth-child(15) {
left: 1%;
width: 6px;
height: 6px;
animation-duration: 5.5s;
}
.p:nth-child(16) {
left: 44%;
width: 8px;
height: 8px;
animation-duration: 4s;
}
.p:nth-child(17) {
left: 87%;
width: 10px;
height: 10px;
animation-duration: 4.5s;
}
.p:nth-child(18) {
left: 38%;
width: 12px;
height: 12px;
animation-duration: 5s;
}
.p:nth-child(19) {
left: 81%;
width: 14px;
height: 14px;
animation-duration: 5.5s;
}
.p:nth-child(20) {
left: 32%;
width: 6px;
height: 6px;
animation-duration: 4s;
}
@keyframes rise {
0% {
transform: translateY(0) scale(0.6);
opacity: 0;
}
12% {
opacity: 0.8;
}
55% {
transform: translateY(-170px) scale(1.15);
}
100% {
transform: translateY(-360px) scale(0.8);
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.p {
animation: none;
opacity: 0.4;
bottom: 20%;
}
}import type { CSSProperties } from "react";
import styles from "./BubblesRise.module.scss";
export type BubblesRiseProps = {
/** 애니메이션 기준 시간(ms) */
duration?: number;
/** CSS easing */
easing?: string;
/** 피사체 색 */
color?: string;
};
type Vars = CSSProperties & { "--i"?: number };
const COUNT = 20;
export default function BubblesRise({
duration = 4000,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#17141a",
}: BubblesRiseProps) {
const vars = {
"--duration": `${duration}ms`,
"--easing": easing,
"--color": color,
} as CSSProperties;
return (
<div className={styles.field} style={vars} aria-hidden="true">
{Array.from({ length: COUNT }, (_, i) => (
<span key={i} className={styles.p} style={{ "--i": i } as Vars} />
))}
</div>
);
}06 · Rising bubbles — Measured area 12.542% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
Behind a beverage or spa hero
It reads well behind a beverage or spa brand hero.
.p {
position: absolute; bottom: -10%; width: 10px; height: 10px;
border: 1.5px solid currentColor; border-radius: 50%;
animation: rise 4s cubic-bezier(.2, .8, .2, 1) infinite;
}
@keyframes rise {
0% { transform: translateY(0) scale(.6); opacity: 0; }
55% { transform: translateY(-170px) scale(1.15); }
100% { transform: translateY(-360px) scale(.8); opacity: 0; }
}
07Fireflies
A path that folds back on itself
16 soft dots glow through a blurred box-shadow and blink via opacity while a seven-stop keyframe changes their translate direction three separate times before folding back to the start. Because the path doubles back on itself instead of running in one line, each dot reads as wandering rather than falling.

<main class="stage">
<div class="field" aria-hidden="true">
<span class="p" style="--i:0"></span>
<span class="p" style="--i:1"></span>
<span class="p" style="--i:2"></span>
<span class="p" style="--i:3"></span>
<span class="p" style="--i:4"></span>
<span class="p" style="--i:5"></span>
<span class="p" style="--i:6"></span>
<span class="p" style="--i:7"></span>
<span class="p" style="--i:8"></span>
<span class="p" style="--i:9"></span>
<span class="p" style="--i:10"></span>
<span class="p" style="--i:11"></span>
<span class="p" style="--i:12"></span>
<span class="p" style="--i:13"></span>
<span class="p" style="--i:14"></span>
<span class="p" style="--i:15"></span>
</div>
<p class="caption">Summer Night Camp</p>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────
$duration: $dur-loop * 1.75; // 3.5s 기준
$easing: ease-in-out;
$color: $subject-cream;
// ── 데모 무대 ───────────────────────────────
@include stage($stage-orange);
.field { position: absolute; inset: 0; overflow: hidden; pointer-events: none; }
.caption {
position: relative; z-index: 1; margin: 0;
color: $subject-cream; font-weight: 700; font-size: clamp(16px, 5vw, 22px); line-height: 1; font-family: inherit;
}
.p {
position: absolute;
width: 4px; height: 4px;
border-radius: 50%;
background: $color;
box-shadow: 0 0 12px rgba($color, .8);
opacity: 0;
animation: drift $duration $easing infinite;
animation-delay: calc(var(--i) * -0.37s);
}
@for $i from 1 through 16 {
.p:nth-child(#{$i}) {
left: ((($i * 29) % 90)) * 1%;
top: ((($i * 61) % 78)) * 1%;
animation-duration: $duration + ($i % 4) * .4s;
}
}
// 짧게 방향을 바꾸며 떠다니는 불규칙한 궤적 (transform 만)
@keyframes drift {
0% { transform: translate(0, 0); opacity: 0; }
20% { opacity: .9; }
35% { transform: translate(14px, -10px); }
55% { transform: translate(-8px, -20px); opacity: .3; }
70% { opacity: .85; }
85% { transform: translate(10px, -6px); }
100% { transform: translate(0, 0); opacity: 0; }
}
// ── 접근성: 깜빡임·이동을 멈추고 흐린 점만 남긴다 ────────────
@media (prefers-reduced-motion: reduce) {
.p { animation: none; opacity: .45; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.field {
position: absolute;
inset: 0;
overflow: hidden;
pointer-events: none;
}
.caption {
position: relative;
z-index: 1;
margin: 0;
color: #fff7e6;
font-weight: 700;
font-size: clamp(16px, 5vw, 22px);
line-height: 1;
font-family: inherit;
}
.p {
position: absolute;
width: 4px;
height: 4px;
border-radius: 50%;
background: #fff7e6;
box-shadow: 0 0 12px rgba(255, 247, 230, 0.8);
opacity: 0;
animation: drift 3.5s ease-in-out infinite;
animation-delay: calc(var(--i) * -0.37s);
}
.p:nth-child(1) {
left: 29%;
top: 61%;
animation-duration: 3.9s;
}
.p:nth-child(2) {
left: 58%;
top: 44%;
animation-duration: 4.3s;
}
.p:nth-child(3) {
left: 87%;
top: 27%;
animation-duration: 4.7s;
}
.p:nth-child(4) {
left: 26%;
top: 10%;
animation-duration: 3.5s;
}
.p:nth-child(5) {
left: 55%;
top: 71%;
animation-duration: 3.9s;
}
.p:nth-child(6) {
left: 84%;
top: 54%;
animation-duration: 4.3s;
}
.p:nth-child(7) {
left: 23%;
top: 37%;
animation-duration: 4.7s;
}
.p:nth-child(8) {
left: 52%;
top: 20%;
animation-duration: 3.5s;
}
.p:nth-child(9) {
left: 81%;
top: 3%;
animation-duration: 3.9s;
}
.p:nth-child(10) {
left: 20%;
top: 64%;
animation-duration: 4.3s;
}
.p:nth-child(11) {
left: 49%;
top: 47%;
animation-duration: 4.7s;
}
.p:nth-child(12) {
left: 78%;
top: 30%;
animation-duration: 3.5s;
}
.p:nth-child(13) {
left: 17%;
top: 13%;
animation-duration: 3.9s;
}
.p:nth-child(14) {
left: 46%;
top: 74%;
animation-duration: 4.3s;
}
.p:nth-child(15) {
left: 75%;
top: 57%;
animation-duration: 4.7s;
}
.p:nth-child(16) {
left: 14%;
top: 40%;
animation-duration: 3.5s;
}
@keyframes drift {
0% {
transform: translate(0, 0);
opacity: 0;
}
20% {
opacity: 0.9;
}
35% {
transform: translate(14px, -10px);
}
55% {
transform: translate(-8px, -20px);
opacity: 0.3;
}
70% {
opacity: 0.85;
}
85% {
transform: translate(10px, -6px);
}
100% {
transform: translate(0, 0);
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.p {
animation: none;
opacity: 0.45;
}
}import type { CSSProperties } from "react";
import styles from "./Fireflies.module.scss";
export type FirefliesProps = {
/** 애니메이션 기준 시간(ms) */
duration?: number;
/** CSS easing */
easing?: string;
/** 피사체 색 */
color?: string;
};
type Vars = CSSProperties & { "--i"?: number };
const COUNT = 16;
export default function Fireflies({
duration = 3500,
easing = "ease-in-out",
color = "#fff7e6",
}: FirefliesProps) {
const vars = {
"--duration": `${duration}ms`,
"--easing": easing,
"--color": color,
} as CSSProperties;
return (
<div className={styles.field} style={vars} aria-hidden="true">
{Array.from({ length: COUNT }, (_, i) => (
<span key={i} className={styles.p} style={{ "--i": i } as Vars} />
))}
</div>
);
}07 · Fireflies — Measured area 1.859% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
A nature brand or a mood portfolio
It fits a nature or camping brand background, or a mood-driven portfolio hero.
.p {
position: absolute; width: 4px; height: 4px; border-radius: 50%;
box-shadow: 0 0 12px rgba(255, 247, 230, .8);
animation: drift 3.5s ease-in-out infinite;
}
@keyframes drift {
0% { transform: translate(0, 0); opacity: 0; }
35% { transform: translate(14px, -10px); opacity: .9; }
55% { transform: translate(-8px, -20px); opacity: .3; }
100% { transform: translate(0, 0); opacity: 0; }
}
08Falling sakura petals
Three sideways nudges on the way down
18 small ellipses fall through a multi-stop keyframe that rotates and nudges each one sideways at three separate points on the way down, instead of dropping straight along one axis. That side-to-side nudge is what reads as wind rather than plain gravity.

<main class="stage">
<div class="field" aria-hidden="true">
<span class="p" style="--i:0"></span>
<span class="p" style="--i:1"></span>
<span class="p" style="--i:2"></span>
<span class="p" style="--i:3"></span>
<span class="p" style="--i:4"></span>
<span class="p" style="--i:5"></span>
<span class="p" style="--i:6"></span>
<span class="p" style="--i:7"></span>
<span class="p" style="--i:8"></span>
<span class="p" style="--i:9"></span>
<span class="p" style="--i:10"></span>
<span class="p" style="--i:11"></span>
<span class="p" style="--i:12"></span>
<span class="p" style="--i:13"></span>
<span class="p" style="--i:14"></span>
<span class="p" style="--i:15"></span>
<span class="p" style="--i:16"></span>
<span class="p" style="--i:17"></span>
</div>
<p class="caption">Spring Collection</p>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────
$duration: $dur-loop * 1.4; // 2.8s 기준
$easing: ease-in-out;
$color: $subject-blue; // 팔레트에 분홍이 없어 blue 로 대신한다 (사실.md 참고)
// ── 데모 무대 ───────────────────────────────
@include stage($stage-paper);
.field { position: absolute; inset: 0; overflow: hidden; pointer-events: none; }
.caption {
position: relative; z-index: 1; margin: 0;
color: $subject-ink; font-weight: 700; font-size: clamp(16px, 5vw, 22px); line-height: 1; font-family: inherit;
}
.p {
position: absolute;
top: -10%;
width: 10px; height: 6px;
border-radius: 50%;
background: $color;
opacity: 0;
animation: petal $duration $easing infinite;
animation-delay: calc(var(--i) * -0.28s);
}
@for $i from 1 through 18 {
.p:nth-child(#{$i}) {
left: ((($i * 47) % 95)) * 1%;
animation-duration: $duration + ($i % 5) * .4s;
}
}
// 낙하 + 회전 + 좌우 흔들림, 전부 transform 안에서만
@keyframes petal {
0% { transform: translate(0, 0) rotate(0deg); opacity: 0; }
10% { opacity: .85; }
30% { transform: translate(18px, 110px) rotate(120deg); }
60% { transform: translate(-14px, 240px) rotate(240deg); }
100% { transform: translate(10px, 380px) rotate(360deg); opacity: .3; }
}
// ── 접근성: 회전·낙하를 멈추고 옅은 꽃잎만 남긴다 ────────────
@media (prefers-reduced-motion: reduce) {
.p { animation: none; opacity: .3; top: 45%; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.field {
position: absolute;
inset: 0;
overflow: hidden;
pointer-events: none;
}
.caption {
position: relative;
z-index: 1;
margin: 0;
color: #17141a;
font-weight: 700;
font-size: clamp(16px, 5vw, 22px);
line-height: 1;
font-family: inherit;
}
.p {
position: absolute;
top: -10%;
width: 10px;
height: 6px;
border-radius: 50%;
background: #2f6df6;
opacity: 0;
animation: petal 2.8s ease-in-out infinite;
animation-delay: calc(var(--i) * -0.28s);
}
.p:nth-child(1) {
left: 47%;
animation-duration: 3.2s;
}
.p:nth-child(2) {
left: 94%;
animation-duration: 3.6s;
}
.p:nth-child(3) {
left: 46%;
animation-duration: 4s;
}
.p:nth-child(4) {
left: 93%;
animation-duration: 4.4s;
}
.p:nth-child(5) {
left: 45%;
animation-duration: 2.8s;
}
.p:nth-child(6) {
left: 92%;
animation-duration: 3.2s;
}
.p:nth-child(7) {
left: 44%;
animation-duration: 3.6s;
}
.p:nth-child(8) {
left: 91%;
animation-duration: 4s;
}
.p:nth-child(9) {
left: 43%;
animation-duration: 4.4s;
}
.p:nth-child(10) {
left: 90%;
animation-duration: 2.8s;
}
.p:nth-child(11) {
left: 42%;
animation-duration: 3.2s;
}
.p:nth-child(12) {
left: 89%;
animation-duration: 3.6s;
}
.p:nth-child(13) {
left: 41%;
animation-duration: 4s;
}
.p:nth-child(14) {
left: 88%;
animation-duration: 4.4s;
}
.p:nth-child(15) {
left: 40%;
animation-duration: 2.8s;
}
.p:nth-child(16) {
left: 87%;
animation-duration: 3.2s;
}
.p:nth-child(17) {
left: 39%;
animation-duration: 3.6s;
}
.p:nth-child(18) {
left: 86%;
animation-duration: 4s;
}
@keyframes petal {
0% {
transform: translate(0, 0) rotate(0deg);
opacity: 0;
}
10% {
opacity: 0.85;
}
30% {
transform: translate(18px, 110px) rotate(120deg);
}
60% {
transform: translate(-14px, 240px) rotate(240deg);
}
100% {
transform: translate(10px, 380px) rotate(360deg);
opacity: 0.3;
}
}
@media (prefers-reduced-motion: reduce) {
.p {
animation: none;
opacity: 0.3;
top: 45%;
}
}import type { CSSProperties } from "react";
import styles from "./SakuraPetals.module.scss";
export type SakuraPetalsProps = {
/** 애니메이션 기준 시간(ms) */
duration?: number;
/** CSS easing */
easing?: string;
/** 피사체 색 */
color?: string;
};
type Vars = CSSProperties & { "--i"?: number };
const COUNT = 18;
export default function SakuraPetals({
duration = 2800,
easing = "ease-in-out",
color = "#2f6df6",
}: SakuraPetalsProps) {
const vars = {
"--duration": `${duration}ms`,
"--easing": easing,
"--color": color,
} as CSSProperties;
return (
<div className={styles.field} style={vars} aria-hidden="true">
{Array.from({ length: COUNT }, (_, i) => (
<span key={i} className={styles.p} style={{ "--i": i } as Vars} />
))}
</div>
);
}08 · Falling sakura petals — Measured area 9.572% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
A spring promo or a wedding hero
It suits a spring promo background or a wedding and beauty brand hero.
.p { position: absolute; top: -10%; width: 10px; height: 6px; border-radius: 50%; }
@keyframes petal {
0% { transform: translate(0, 0) rotate(0deg); opacity: 0; }
30% { transform: translate(18px, 110px) rotate(120deg); }
60% { transform: translate(-14px, 240px) rotate(240deg); }
100% { transform: translate(10px, 380px) rotate(360deg); opacity: .3; }
}
09Drifting fog
One loop, three staggered starts
Three wide, blurred bands share one back-and-forth translateX loop running at the same speed, but animation-delay staggers when each band starts so all three overlap instead of moving as one flat sheet. Opacity breathes along the same cycle, which keeps the layer from ever settling into a static frame.

<main class="stage">
<div class="field" aria-hidden="true">
<span class="p" style="--i:0"></span>
<span class="p" style="--i:1"></span>
<span class="p" style="--i:2"></span>
</div>
<p class="caption">Something's Coming</p>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────
$duration: $dur-loop; // 2s 기준 — 캡처 구간과 맞춰야 한 번의 이동이 가득 잡힌다
$easing: linear; // 일정한 속도라야 프레임마다 이동량이 고르게 나온다 (ease 는 시작·끝이 너무 느려 측정에 안 걸렸다)
$color: $subject-cream;
// ── 데모 무대 ───────────────────────────────
@include stage($stage-ink);
.field { position: absolute; inset: 0; overflow: hidden; pointer-events: none; }
.caption {
position: relative; z-index: 1; margin: 0;
color: $subject-cream; font-weight: 700; font-size: clamp(16px, 5vw, 22px); line-height: 1; font-family: inherit;
}
// 층마다 폭이 넓은 흐릿한 띠. gap 없이 절대 위치로만 겹친다
// blur 를 세게 주면 가장자리 전이 구간이 넓어져, 몇 px 이동해도 픽셀당 변화가 문턱(12)을 못 넘는다 —
// blur 를 줄이고 이동 폭을 크게 줘야 프레임마다 실제로 측정 가능한 변화가 된다 (실측)
.p {
position: absolute;
left: -20%;
width: 140%;
height: 34%;
border-radius: $r-card;
background: $color;
opacity: .3;
filter: blur(6px); // 정적. 애니메이션은 transform·opacity 만
animation: driftx $duration $easing infinite alternate; // 왕복이라 이음새가 필요 없다
}
.p:nth-child(1) { top: 8%; animation-delay: 0s; }
.p:nth-child(2) { top: 38%; animation-delay: $duration * -.33; }
.p:nth-child(3) { top: 64%; animation-delay: $duration * -.66; }
@keyframes driftx {
from { transform: translateX(-30%); opacity: .18; }
to { transform: translateX(30%); opacity: .42; }
}
// ── 접근성: 흐름을 멈추고 고정된 안개층만 남긴다 ────────────
@media (prefers-reduced-motion: reduce) {
.p { animation: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.field {
position: absolute;
inset: 0;
overflow: hidden;
pointer-events: none;
}
.caption {
position: relative;
z-index: 1;
margin: 0;
color: #fff7e6;
font-weight: 700;
font-size: clamp(16px, 5vw, 22px);
line-height: 1;
font-family: inherit;
}
.p {
position: absolute;
left: -20%;
width: 140%;
height: 34%;
border-radius: 16px;
background: #fff7e6;
opacity: 0.3;
filter: blur(6px);
animation: driftx 2s linear infinite alternate;
}
.p:nth-child(1) {
top: 8%;
animation-delay: 0s;
}
.p:nth-child(2) {
top: 38%;
animation-delay: -0.66s;
}
.p:nth-child(3) {
top: 64%;
animation-delay: -1.32s;
}
@keyframes driftx {
from {
transform: translateX(-30%);
opacity: 0.18;
}
to {
transform: translateX(30%);
opacity: 0.42;
}
}
@media (prefers-reduced-motion: reduce) {
.p {
animation: none;
}
}import type { CSSProperties } from "react";
import styles from "./FogDrift.module.scss";
export type FogDriftProps = {
/** 애니메이션 기준 시간(ms) */
duration?: number;
/** CSS easing */
easing?: string;
/** 피사체 색 */
color?: string;
};
type Vars = CSSProperties & { "--i"?: number };
const COUNT = 3;
export default function FogDrift({
duration = 2000,
easing = "linear",
color = "#fff7e6",
}: FogDriftProps) {
const vars = {
"--duration": `${duration}ms`,
"--easing": easing,
"--color": color,
} as CSSProperties;
return (
<div className={styles.field} style={vars} aria-hidden="true">
<span className={styles.p} />
<span className={styles.p} />
<span className={styles.p} />
</div>
);
}09 · Drifting fog — Measured area 90.962% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
A mystery hero or an outdoor brand
It fits a mystery or horror content hero, or a mountain and outdoor brand background.
.p {
position: absolute; left: -20%; width: 140%; height: 34%;
filter: blur(6px);
animation: driftx 2s linear infinite alternate;
}
.p:nth-child(2) { animation-delay: -0.66s; }
.p:nth-child(3) { animation-delay: -1.32s; }
@keyframes driftx {
from { transform: translateX(-30%); opacity: .18; }
to { transform: translateX(30%); opacity: .42; }
}
Where do these particles actually break?
Three separate bugs showed up while building this set, and each one hid in a different layer of the pipeline.
Percent resolves against the particle
The first sits inside translate() itself. Its percentage values resolve against the moving element's own box, not the stage it crosses. The pure css snow effect and four others — rain, confetti, bubbles, and petals — were first written with something like translateY(370%), on the assumption that a particle would cross most of the stage. A 6px circle moving 370% of its own 6px height only travels 6 to 22 pixels, and the field barely twitched: item 01's cumulative-area reading sat at 0.15% before anyone noticed. Switching all five to a fixed pixel distance — translateY(340px) for the snow — pushed that same reading to 11.61%.
Why a stronger blur reads as still
The second lives in item 09's blur. An early pass layered filter: blur(16px) on top of the opacity fade. The cumulative-area comparison between the first and last captured frame still read 94% — the band had, after all, ended up somewhere different — but the frame-to-frame movement count came back zero. Blur spreads a shape's edge across a wide, gradual transition instead of a hard line. The change between any two adjacent frames then fell under the pixel threshold this site's measurement script uses to call a frame "moved." Cutting the blur down and widening the band's own translateX range fixed it without touching the opacity curve.
Duration outrunning the capture window
The third is duration outrunning the capture window. That same fog band was first tuned to a 10-second loop, but this site's capture window runs only 2.0 seconds — a sliver that lands entirely inside ease-in-out's slow opening curve. So the reading came back as motionless. Matching the loop length to that 2.0-second window let the sweep register as motion again. Every one of the nine ships here with all three of those already fixed, inside an archive that only opens once fjv3dy5x goes in precisely as it's spelled on this page.
The overlong-loop trap that caught this fog band in 09 bites hardest on big sweeping shapes — the wave sections in the Memphis design pattern kit can measure as a still frame for the very same reason whenever their sweep outlasts the capture window.
Accessibility
Nothing newer than Baseline
All nine lean on nothing newer than transform, opacity, @keyframes, :nth-child(), and CSS custom properties, which have been Baseline and widely available for years — no vendor prefix or fallback branch needed anywhere in the set. The one exception worth flagging is item 09's filter: blur(), which reaches back further still, to Chrome 53, Safari 9.1, and Firefox 35.
What reduced motion leaves behind
Under prefers-reduced-motion, none of the nine simply vanishes — each one keeps a faint, still trace of itself instead. Snow, rain, confetti, and petals stop moving and settle at a low fixed opacity near the same spot they'd otherwise be crossing. Stars and fireflies hold at a fixed glow instead of blinking. The wave layers and the fog bands stop outright, freezing the last painted frame, while bubbles hold near the bottom of their rise instead of resetting to invisible.
@media (prefers-reduced-motion: reduce) {
.p { animation: none; opacity: .35; }
}
Where to go next
MDN's prefers-reduced-motion page covers the operating-system settings that flip this media query on. Browse other CSS-only patterns from the CSS category hub, or see what this site does and doesn't sell on the about page.
FAQ
Can I use just one of these as a single css particle background, not the whole set?
Yes. Every item's SCSS lives in its own .p block with a particle count and keyframes that never reference any other item. Lifting one span loop and its matching HTML out of the zip is enough on its own. Nothing here assumes the other eight are also on the page. And since every scatter here is fixed at compile time, a page can carry several of these backgrounds side by side — the seeded background pattern generator pushes that same idea further, redrawing one fixed scatter from a single seed value.
Does a css background particle animation slow the page down with more particles?
Not by itself. Every one of the nine only animates transform and opacity, properties a browser can composite on their own layer without recalculating the page's layout each frame. So the span count — from 3 in the fog to 26 in the confetti set — matters far less than which properties move. The exception is item 09's blur, which is why it's spread across three wide bands rather than scaled up to two dozen small ones. A blur's repaint cost tracks the area it covers, not the number of elements carrying it.
Why do the falling petals look blue instead of pink?
This site's demo palette has no pink in it, so item 08 borrows $subject-blue rather than adding a color used nowhere else on the site. Once the zip is open, swapping it back to pink is a one-line change to the $color variable and nothing else in the file needs to move.









