6/16/2025
Three.js particle effects — fire effect in a hero section, step by step
TL;DR: Three.js BufferGeometry with 2000 particles + custom shader material = fire effect behind the hero. 60fps on desktop, CSS fallback on mobile. The whole effect is less than 50 lines of JavaScript.
Particle effects in hero section backgrounds still make an impression — especially when they’re subtle and don’t slow down the page. Three.js achieves 60fps with thousands of particles because the calculations go to the GPU via WebGL. I’ll walk through a complete implementation of a fire effect, step by step.
Scene setup
Start with a basic Three.js scene with a transparent background:
import * as THREE from 'three';
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(
75, window.innerWidth / window.innerHeight, 0.1, 1000
);
camera.position.z = 5;
const renderer = new THREE.WebGLRenderer({
canvas: document.querySelector('#fire-canvas'),
alpha: true, // transparent background — HTML visible beneath canvas
antialias: false, // disable for performance (particles don't need it)
});
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
// Resize handler
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});
Key point: alpha: true lets you see HTML content beneath the canvas. The canvas is set as position: absolute in the background of the hero section via CSS.
Particle system
const PARTICLE_COUNT = 2000;
// Positions: 3 values (x, y, z) per particle
const positions = new Float32Array(PARTICLE_COUNT * 3);
const colors = new Float32Array(PARTICLE_COUNT * 3);
const speeds = new Float32Array(PARTICLE_COUNT);
for (let i = 0; i < PARTICLE_COUNT; i++) {
const i3 = i * 3;
// Random starting position in range -3..3 width, -2..2 height
positions[i3] = (Math.random() - 0.5) * 6; // x
positions[i3 + 1] = (Math.random() - 0.5) * 4; // y
positions[i3 + 2] = (Math.random() - 0.5) * 2; // z
speeds[i] = 0.005 + Math.random() * 0.015;
}
const geometry = new THREE.BufferGeometry();
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3));
const material = new THREE.PointsMaterial({
size: 0.05,
sizeAttenuation: true,
transparent: true,
opacity: 0.8,
vertexColors: true,
blending: THREE.AdditiveBlending, // colors blend like fire
depthWrite: false,
});
const particles = new THREE.Points(geometry, material);
scene.add(particles);
AdditiveBlending is the secret to the fire effect — overlapping particles sum their colors, creating a brighter, more intense center.
Particle motion
In the render loop you update the position of each particle:
let time = 0;
function animate() {
requestAnimationFrame(animate);
time += 0.01;
const pos = geometry.attributes.position.array;
const col = geometry.attributes.color.array;
for (let i = 0; i < PARTICLE_COUNT; i++) {
const i3 = i * 3;
// Move upward
pos[i3 + 1] += speeds[i];
// Slight X-axis flutter — fire flickering effect
pos[i3] += Math.sin(time + i * 0.1) * 0.002;
// Reset when particle exits the top
if (pos[i3 + 1] > 2.5) {
pos[i3 + 1] = -2.5;
pos[i3] = (Math.random() - 0.5) * 4; // random x position on reset
}
// Color based on height: yellow bottom → orange middle → red top
const lifeRatio = (pos[i3 + 1] + 2.5) / 5.0; // 0..1
col[i3] = 1.0; // R always full
col[i3 + 1] = Math.max(0, 0.8 - lifeRatio); // G decreases with age
col[i3 + 2] = 0.0; // B zero
}
geometry.attributes.position.needsUpdate = true;
geometry.attributes.color.needsUpdate = true;
renderer.render(scene, camera);
}
animate();
Both needsUpdate = true flags are essential — without them Three.js won’t send updated data to the GPU.
Performance on mobile
WebGL on weaker mobile devices may not achieve 60fps with 2000 particles. Instead of degrading the effect, replace it with CSS:
const isMobile = window.matchMedia('(max-width: 768px)').matches;
if (isMobile) {
// Replace canvas with CSS gradient
document.querySelector('#fire-canvas').style.display = 'none';
document.querySelector('.hero').style.background =
'radial-gradient(ellipse at 50% 100%, #ff6b00 0%, #ff0000 40%, transparent 70%)';
} else {
// Start Three.js
initFireEffect();
}
Additional optimization: stop the render loop when the canvas is out of the viewport (user scrolled away):
const canvasObserver = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) {
renderer.setAnimationLoop(animate);
} else {
renderer.setAnimationLoop(null); // stop render loop
}
});
canvasObserver.observe(document.querySelector('#fire-canvas'));
Summary
A Three.js particle system built on BufferGeometry is an efficient foundation for particle effects. The keys to 60fps: only geometry updates in the render loop (not creating new objects), AdditiveBlending for natural color blending, and stopping the render loop when out of viewport. CSS fallback on mobile is not a compromise — it’s good engineering.