perkun.eu Services Portfolio Blog About Contact PL
← Blog

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.