Snowflake Effect
A GSAP-powered snowflake particle system that clones a template element, animates each flake falling with random scale, opacity, sway, and rotation, and fades it out before removal. Strength and infinite/burst mode are controlled via data attributes. Prevents double-initialization and caps active flakes.
Setup — External Scripts
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/gsap.min.js"></script>Code
<div data-snowflake-container data-strength="4" data-infinite="true" class="snowflake-container">
<div data-snowflake class="snowflake-el hidden"></div>
</div>.snowflake-container {
z-index: 100;
pointer-events: none;
width: 100%;
height: 100vh;
position: fixed;
inset: 0%;
overflow: hidden;
}
.snowflake-el {
aspect-ratio: 1 / 1.15;
background-image: url('your-snowflake-image.avif');
background-position: 50%;
background-repeat: no-repeat;
background-size: contain;
width: 1.5em;
position: absolute;
}
.snowflake-el.hidden {
opacity: 0;
}function initSnowflakeEffect() {
const container = document.querySelector("[data-snowflake-container]");
if (!container) return;
if (container.dataset.snowRunning === "true") return;
container.dataset.snowRunning = "true";
const templates = Array.from(container.querySelectorAll("[data-snowflake]"));
if (!templates.length) {
console.warn("initSnowflakeEffect: No [data-snowflake] element found");
container.dataset.snowRunning = "false";
return;
}
const clamp = (n, min, max) => Math.max(min, Math.min(max, n));
const strength = clamp(parseInt(container.dataset.strength ?? "4", 10) || 0, 0, 10);
const infinite = (container.dataset.infinite ?? "true") !== "false";
const durationMin = 8;
const durationMax = 12;
const scaleMin = 0.3;
const scaleMax = 1.2;
const opacityMin = 0.2;
const opacityMax = 1.0;
const spawnRate = gsap.utils.mapRange(0, 10, 0.15, 5.0, strength);
const maxOnScreen = Math.round(gsap.utils.mapRange(0, 10, 12, 180, strength));
const burstCount = Math.round(gsap.utils.mapRange(0, 10, 10, 160, strength));
let running = true;
let activeCount = 0;
let scheduledCall = null;
let burstSpawned = 0;
const getHeight = () => container.clientHeight || window.innerHeight;
function stop(removeExisting = true) {
running = false;
container.dataset.snowRunning = "false";
if (scheduledCall) scheduledCall.kill();
if (removeExisting) {
container.querySelectorAll(".snowflake-el.is-spawned").forEach(el => el.remove());
activeCount = 0;
}
}
function cleanupFlake(flake, tweens) {
tweens.forEach(t => t && t.kill());
flake.remove();
activeCount--;
if (!infinite && burstSpawned >= burstCount && activeCount <= 0) {
stop(false);
}
}
function spawnOne() {
if (!running) return;
if (activeCount >= maxOnScreen) return;
const tpl = templates[Math.floor(Math.random() * templates.length)];
const flake = tpl.cloneNode(true);
flake.classList.remove("hidden");
flake.classList.add("is-spawned");
flake.style.willChange = "transform, opacity";
const scale = gsap.utils.random(scaleMin, scaleMax, 0.001);
const duration = gsap.utils.random(durationMin, durationMax, 0.001);
const baseSway = gsap.utils.random(12, 60, 0.1);
const sway = baseSway * (0.6 + strength / 20);
const containerWidth = container.clientWidth || window.innerWidth;
const swayPct = (sway / containerWidth) * 100;
const padPct = Math.min(20, Math.max(0, swayPct));
flake.style.left = `${gsap.utils.random(padPct, 100 - padPct, 0.1)}%`;
flake.style.opacity = gsap.utils.random(opacityMin, opacityMax, 0.001);
container.appendChild(flake);
activeCount++;
const h = getHeight();
const startY = -gsap.utils.random(30, Math.min(180, h * 0.25), 1);
const endY = h + gsap.utils.random(30, Math.min(220, h * 0.35), 1);
const xStart = gsap.utils.random(-sway, sway, 0.1);
const xEnd = -xStart;
const swayDur = gsap.utils.random(1.6, 3.8, 0.001);
const rotStart = gsap.utils.random(-12, 12, 0.1);
const rotEnd = gsap.utils.random(-28, 28, 0.1);
const rotDur = gsap.utils.random(2.2, 5.0, 0.001);
let fallTween, swayTween, rotTween, fadeTween;
fallTween = gsap.fromTo(
flake,
{ y: startY, xPercent: -50, scale, rotate: rotStart },
{
y: endY,
xPercent: -50,
ease: "none",
duration,
onComplete: () => cleanupFlake(flake, [fallTween, swayTween, rotTween, fadeTween]),
}
);
const swayRepeats = Math.max(1, Math.floor(duration / swayDur));
swayTween = gsap.fromTo(
flake,
{ x: xStart },
{ x: xEnd, ease: "sine.inOut", duration: swayDur, repeat: swayRepeats, yoyo: true }
);
const rotRepeats = Math.max(1, Math.floor(duration / rotDur));
rotTween = gsap.fromTo(
flake,
{ rotate: rotStart },
{ rotate: rotEnd, ease: "sine.inOut", duration: rotDur, repeat: rotRepeats, yoyo: true }
);
fadeTween = gsap.to(flake, {
opacity: 0,
duration: 1,
ease: "power1.out",
delay: Math.max(0, duration - 1),
});
}
function scheduleNext() {
if (!running) return;
const avgGap = 1 / spawnRate;
const nextIn = gsap.utils.random(avgGap * 0.6, avgGap * 1.4, 0.001);
scheduledCall = gsap.delayedCall(nextIn, () => {
spawnOne();
scheduleNext();
});
}
if (infinite) {
const seedCount = Math.round(gsap.utils.mapRange(0, 10, 6, 60, strength));
for (let i = 0; i < seedCount; i++) {
gsap.delayedCall(gsap.utils.random(0, 1.2, 0.001), spawnOne);
}
scheduleNext();
} else {
for (let i = 0; i < burstCount; i++) {
burstSpawned++;
gsap.delayedCall(gsap.utils.random(0, 2.0, 0.001), spawnOne);
}
}
}
document.addEventListener("DOMContentLoaded", () => {
initSnowflakeEffect();
});Attributes
| Name | Type | Default | Description |
|---|---|---|---|
| data-snowflake-container | attribute | — | Root container that defines the bounds for spawning. Also holds data-strength and data-infinite. The script checks this for double-init prevention. |
| data-strength | number (0–10) | "4" | Controls overall intensity. Scales spawn rate, max on-screen count, burst count, seed count, and sway width. Clamped to 0–10. |
| data-infinite | "true" | "false" | "true" | When "true", flakes spawn continuously on a randomized interval. When "false", a fixed burst is spawned once and the effect stops after all flakes finish. |
| data-snowflake | attribute | — | The template element that is cloned for each spawned flake. Should include the .hidden class so it is invisible until cloned. Multiple templates are supported — one is chosen at random per flake. |
Notes
- •Requires GSAP loaded via CDN before the script runs.
- •The script prevents double-initialization via a data-snow-running flag on the container.
- •In burst mode (data-infinite="false"), the effect auto-stops after all flakes have exited and been cleaned up.
- •The container should have overflow: hidden to clip flakes that drift near the edges.
- •Replace the background-image URL in .snowflake-el with your own snowflake asset.
Guide
Spawn and cleanup lifecycle
Each flake is a clone of the [data-snowflake] template. After it falls past the bottom of the container, its tweens are killed, the element is removed from the DOM, and the active count decrements. In burst mode, when all spawned flakes finish, the effect stops itself.
Sway and rotation
Each flake runs three independent tweens simultaneously: a linear fall (y), a yoyo sway (x), and a yoyo rotation. The sway amplitude is influenced by data-strength, so higher values produce wider left-right drift.
Tuning per-flake randomness
Edit the six constants at the top of the function to change the random ranges applied per flake.
const durationMin = 8; // seconds to fall
const durationMax = 12;
const scaleMin = 0.3; // smallest flake
const scaleMax = 1.2; // largest flake
const opacityMin = 0.2;
const opacityMax = 1.0;Multiple templates
Place several [data-snowflake] elements inside the container to use different snowflake images or shapes. The script picks one at random for each spawned flake.