How to Add a Simple Snowfall Effect to Your Website
A seasonal holiday website animation can delight visitors, but most tutorials ignore what matters in production: performance, accessibility, and user experience. You don’t want a decorative background animation that tanks your Core Web Vitals or annoys users who prefer reduced motion.
This guide shows you how to build a lightweight canvas snowfall effect that respects user preferences, pauses when invisible, and stays out of the way. You’ll also learn when a simpler CSS snowfall effect makes sense.
Key Takeaways
- Canvas scales more reliably than DOM-based approaches once you move beyond a small handful of particles
- Always respect
prefers-reduced-motionto honor user accessibility preferences - Use the Page Visibility API to pause animations in background tabs and save resources
- Pure CSS snowfall works for minimal implementations (5-10 snowflakes) but doesn’t scale well
Why Canvas for JavaScript Snowfall Animation
DOM-based approaches create individual elements for each snowflake. This works for a handful of particles, but scaling to dozens or hundreds means constant DOM manipulation, layout recalculations, and memory pressure from element creation and removal.
A canvas snowfall effect draws everything to a single element. You control the render loop, manage particle state in plain arrays, and avoid DOM overhead entirely. Once you scale beyond a small number of particles or want smoother motion, canvas becomes the more predictable default.
Trade-offs to understand:
- Canvas requires JavaScript—no JS means no snow
- Text inside canvas isn’t accessible to screen readers (fine for purely decorative effects)
- CSS animations aren’t “free”—they still consume CPU/GPU resources
Setting Up the Canvas Element
Position the canvas behind your content with CSS:
#snowfall {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: -1;
}
The pointer-events: none rule ensures the canvas never blocks scrolling, clicking, or any user interaction.
<canvas id="snowfall" aria-hidden="true"></canvas>
Adding aria-hidden="true" tells assistive technologies to ignore this purely decorative element.
Building the Animation Loop
Here’s a minimal implementation with proper guardrails:
const canvas = document.getElementById('snowfall');
const ctx = canvas.getContext('2d');
let flakes = [];
let animationId = null;
function resize() {
const dpr = window.devicePixelRatio || 1;
canvas.width = window.innerWidth * dpr;
canvas.height = window.innerHeight * dpr;
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.scale(dpr, dpr);
}
function createFlake() {
return {
x: Math.random() * window.innerWidth,
y: -10,
radius: Math.random() * 3 + 1,
speed: Math.random() * 1 + 0.5,
opacity: Math.random() * 0.6 + 0.4
};
}
function update() {
ctx.clearRect(0, 0, window.innerWidth, window.innerHeight);
if (flakes.length < 80 && Math.random() > 0.95) {
flakes.push(createFlake());
}
flakes = flakes.filter(f => {
f.y += f.speed;
ctx.beginPath();
ctx.arc(f.x, f.y, f.radius, 0, Math.PI * 2);
ctx.fillStyle = `rgba(255, 255, 255, ${f.opacity})`;
ctx.fill();
return f.y < window.innerHeight + 10;
});
animationId = requestAnimationFrame(update);
}
resize();
window.addEventListener('resize', resize);
This handles high-DPI screens correctly by scaling the canvas buffer to match devicePixelRatio. The ctx.setTransform(1, 0, 0, 1, 0, 0) call resets the transformation matrix before applying the new scale, preventing cumulative scaling on window resize.
Discover how at OpenReplay.com.
Essential Performance and Accessibility Guardrails
Respecting User Preferences
Users who set prefers-reduced-motion have explicitly asked for less animation. Honor that:
const reducedMotionQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
let prefersReduced = reducedMotionQuery.matches;
reducedMotionQuery.addEventListener('change', (e) => {
prefersReduced = e.matches;
if (prefersReduced) {
cancelAnimationFrame(animationId);
ctx.clearRect(0, 0, window.innerWidth, window.innerHeight);
} else if (!document.hidden) {
update();
}
});
if (!prefersReduced) {
update();
}
This implementation listens for changes to the user’s motion preference, allowing the animation to respond dynamically if the setting changes while the page is open.
Pausing When Hidden
Running animations in background tabs wastes battery and CPU. The Page Visibility API solves this:
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
cancelAnimationFrame(animationId);
} else if (!prefersReduced) {
update();
}
});
When CSS Snowfall Effects Make Sense
For very small implementations—perhaps 5-10 snowflakes on a hero section—a pure CSS approach avoids JavaScript entirely:
.snowflake {
position: absolute;
color: white;
animation: fall 8s linear infinite;
}
@keyframes fall {
to { transform: translateY(100vh); }
}
This works for minimal decorative use cases but doesn’t scale. Each element still triggers compositing, and you lose programmatic control over density and behavior.
Customization Options
Adjust these values to match your site’s aesthetic:
- Density: Change the
80cap and0.95spawn threshold - Speed range: Modify the
Math.random() * 1 + 0.5calculation - Size: Adjust the radius calculation
- Scope: Target a specific container instead of
window.innerWidth/Height
Conclusion
A holiday website animation should add to the experience without compromising it. Use canvas for scalable performance, respect prefers-reduced-motion, pause when the page isn’t visible, and keep the effect non-interactive. Start with conservative particle counts and adjust based on actual device testing—not assumptions about what browsers can handle.
FAQs
A well-implemented canvas animation has minimal impact on Core Web Vitals. Since canvas renders to a single element and uses requestAnimationFrame, it won't cause layout shifts or block the main thread. Keep particle counts reasonable (under 100) and pause the animation when the tab is hidden to maintain good performance scores.
Yes. Add a drift property to each flake object and update the x position in your animation loop alongside the y position. Use Math.sin with a time-based offset for natural-looking oscillation, or apply a constant horizontal value for steady wind. Randomize drift values per flake for variety.
Replace window.innerWidth and window.innerHeight with the dimensions of your target container. Use getBoundingClientRect to get the container's size and position. Change the canvas CSS from position fixed to position absolute and place it inside your target container element.
This happens when the canvas buffer size doesn't match the display's pixel density. The devicePixelRatio scaling in the code sample fixes this by creating a larger canvas buffer and scaling the drawing context. Make sure you apply this scaling in your resize function and reset the transform matrix before each scale operation.
Understand every bug
Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data. Check our GitHub repo and join the thousands of developers in our community.