Creating Bokeh Particles with Canvas in Svelte - Part 2: Building the Particle System

Implementing smooth particle movement, opacity animations, and responsive behavior

Introduction

In Part 1, we covered the fundamentals of Canvas and how to draw Bokeh particles. Now, let's build a complete particle system with smooth animations and dynamic behavior.

Table of Contents

Q: How do I structure particle data?

A: Each particle needs properties for position, movement, appearance, and animation state:

interface Particle {
	x: number;              // Current X position
	y: number;              // Current Y position
	vx: number;             // Velocity X (movement speed)
	vy: number;             // Velocity Y
	radius: number;         // Current radius (can vary)
	opacity: number;        // Current opacity
	targetOpacity: number;  // Target opacity for smooth transitions
	color: string;          // Particle color
	baseRadius: number;     // Base radius (for size variations)
}

This structure allows us to:

  • Track position and movement independently
  • Animate opacity smoothly toward a target
  • Vary size dynamically while keeping a base value

Q: How do I create and initialize particles?

A: Create particles with random initial values:

const particleCount = 50;
const minRadius = 2;
const maxRadius = 8;
const speed = 0.3;
const opacityRange = { min: 0.2, max: 0.6 };

function createParticle(width: number, height: number): Particle {
	const radius = minRadius + Math.random() * (maxRadius - minRadius);
	const color = colorVariations[Math.floor(Math.random() * colorVariations.length)];
	const opacity = opacityRange.min + Math.random() * (opacityRange.max - opacityRange.min);

	return {
		x: Math.random() * width,
		y: Math.random() * height,
		vx: (Math.random() - 0.5) * speed,
		vy: (Math.random() - 0.5) * speed,
		radius,
		opacity,
		targetOpacity: opacity,
		color,
		baseRadius: radius
	};
}

function initParticles(width: number, height: number) {
	particles = [];
	for (let i = 0; i < particleCount; i++) {
		particles.push(createParticle(width, height));
	}
}

Design decisions:

  • Random positions: Math.random() * width/height distributes particles across the canvas
  • Random velocities: (Math.random() - 0.5) * speed creates movement in all directions
  • Varied sizes: Different radii create visual interest
  • Color variations: Using an array of similar colors maintains visual cohesion

Q: How do I update particle positions smoothly?

A: Update position each frame and handle edge wrapping:

function updateParticle(particle: Particle, width: number, height: number) {
	// Update position
	particle.x += particle.vx;
	particle.y += particle.vy;

	// Wrap around edges (seamless loop)
	if (particle.x < 0) particle.x = width;
	if (particle.x > width) particle.x = 0;
	if (particle.y < 0) particle.y = height;
	if (particle.y > height) particle.y = 0;
}

Why wrap instead of bounce?

  • Seamless animation: No jarring direction changes
  • Infinite space illusion: Particles appear to move continuously
  • Simpler code: No need to reverse velocity

Q: How do I create smooth opacity transitions?

A: Use linear interpolation (lerp) to smoothly transition toward a target:

function updateParticle(particle: Particle, width: number, height: number) {
	// ... position updates ...
	
	// Smooth opacity animation (lerp)
	const opacityDiff = particle.targetOpacity - particle.opacity;
	particle.opacity += opacityDiff * 0.02;
	
	// Occasionally change target opacity for breathing effect
	if (Math.random() < 0.005) {
		particle.targetOpacity = opacityRange.min + 
			Math.random() * (opacityRange.max - opacityRange.min);
	}
}

How it works:

  • Lerp factor (0.02): Moves 2% toward target each frame
  • Small factor = smooth: Larger values (0.1+) create faster, less smooth transitions
  • Random target changes: Creates a "breathing" effect where particles gently fade in and out
  • Low probability (0.005): Only changes target ~0.5% of frames, keeping it subtle

Q: How do I add subtle size variations?

A: Use sine waves for smooth, organic size changes:

function updateParticle(particle: Particle, width: number, height: number) {
	// ... other updates ...
	
	// Subtle size variation using sine wave
	particle.radius = particle.baseRadius + 
		Math.sin(Date.now() * 0.001 + particle.x) * 0.5;
}

Why sine waves?

  • Smooth oscillation: Creates natural pulsing effect
  • Unique per particle: Adding particle.x to the phase makes each particle pulse at different times
  • Slow animation: Date.now() * 0.001 creates slow, subtle changes
  • Small variation: * 0.5 keeps the size change subtle

Q: How do I handle responsive particle counts?

A: Adjust particle count based on viewport size for performance:

function getParticleCount(): number {
	if (!browser) return 50;
	
	const width = window.innerWidth;
	if (width < 768) return 30;      // Mobile: fewer particles
	if (width < 1024) return 40;     // Tablet: medium
	return 50;                        // Desktop: full count
}

function initParticles(width: number, height: number) {
	const count = getParticleCount();
	particles = [];
	for (let i = 0; i < count; i++) {
		particles.push(createParticle(width, height));
	}
}

Performance benefits:

  • Mobile optimization: Fewer particles = better battery life
  • Smooth 60fps: Maintains performance on all devices
  • Scalable: Easy to adjust thresholds based on testing

Q: How do I integrate everything in the animation loop?

A: The complete animation loop looks like this:

function animate() {
	if (!ctx || !canvas) return;

	const rect = canvas.getBoundingClientRect();
	ctx.clearRect(0, 0, rect.width, rect.height);

	// Update and draw all particles
	particles.forEach(particle => {
		updateParticle(particle, rect.width, rect.height);
		drawBokehParticle(ctx, particle);
	});

	animationFrameId = requestAnimationFrame(animate);
}

Order matters:

  1. Clear first: Remove previous frame
  2. Update then draw: Always update state before rendering
  3. Use requestAnimationFrame: For smooth, synced animation

Complete Integration Example

Here's how it all comes together in a Svelte component:

<script lang="ts">
	import { onMount, onDestroy } from 'svelte';
	import { browser } from '$app/environment';

	let canvas: HTMLCanvasElement;
	let ctx: CanvasRenderingContext2D | null = null;
	let animationFrameId: number;
	let particles: Particle[] = [];

	onMount(() => {
		if (!browser) return;
		
		initCanvas();
		initParticles(canvas.width, canvas.height);
		animate();
	});

	onDestroy(() => {
		if (animationFrameId) {
			cancelAnimationFrame(animationFrameId);
		}
	});
</script>

<canvas bind:this={canvas} class="particles-canvas"></canvas>

Q: How do I add a glow effect to some particles?

A: Adding a rotating glow effect creates visual interest and makes certain particles stand out:

interface Particle {
	// ... other properties ...
	hasGlow: boolean;
	glowAngle: number;
	glowIntensity: number;
}

const glowProbability = 0.25; // 25% of particles will have glow

function createParticle(): Particle {
	const hasGlow = Math.random() < glowProbability;
	return {
		// ... other properties ...
		hasGlow,
		glowAngle: Math.random() * Math.PI * 2,
		glowIntensity: 0.7 + Math.random() * 0.3
	};
}

function drawGlow(particle: Particle) {
	if (!ctx) return;

	// Calculate glow position (offset from center, rotating)
	const glowDistance = particle.radius * 0.25;
	const glowX = particle.x + Math.cos(particle.glowAngle) * glowDistance;
	const glowY = particle.y + Math.sin(particle.glowAngle) * glowDistance;
	const glowRadius = particle.radius * 0.6 * particle.glowIntensity;

	// Create bright white gradient for glow
	const glowGradient = ctx.createRadialGradient(
		glowX, glowY, 0,
		glowX, glowY, glowRadius
	);

	const baseGlowOpacity = Math.min(1.0, particle.opacity * 1.5) * particle.glowIntensity;
	const glowAlpha = Math.floor(baseGlowOpacity * 255).toString(16).padStart(2, '0');
	
	glowGradient.addColorStop(0, `#FFFFFF${glowAlpha}`);
	glowGradient.addColorStop(0.3, `#FFFFFF${Math.floor(baseGlowOpacity * 0.6 * 255).toString(16).padStart(2, '0')}`);
	glowGradient.addColorStop(1, '#FFFFFF00');

	ctx.fillStyle = glowGradient;
	ctx.beginPath();
	ctx.arc(glowX, glowY, glowRadius, 0, Math.PI * 2);
	ctx.fill();

	// Add smaller highlight point for extra sparkle
	const highlightRadius = particle.radius * 0.15;
	// ... draw highlight ...
}

function updateParticle(particle: Particle) {
	// ... other updates ...
	
	if (particle.hasGlow) {
		// Slow rotation of glow position
		particle.glowAngle += 0.01;
		if (particle.glowAngle > Math.PI * 2) {
			particle.glowAngle -= Math.PI * 2;
		}
		
		// Subtle intensity pulsing
		particle.glowIntensity = 0.7 + Math.sin(Date.now() * 0.002 + particle.x * 0.1) * 0.25;
	}
}

Key features:

  • Rotating glow: The glow position rotates slowly around the particle
  • Pulsing intensity: Creates a "breathing" effect
  • Layered rendering: Main glow + smaller highlight point for depth
  • High opacity: Stronger than base particles for visibility

Q: How do I create sharp, bright particles for contrast?

A: Adding rare, sharp particles creates visual variety and highlights:

interface Particle {
	// ... other properties ...
	isSharp: boolean;
}

const sharpProbability = 0.08; // 8% of particles
const sharpColors = ['#FFFFFF', '#F5E6FF', '#E8D5FF', '#FFE5FF'];

function createParticle(): Particle {
	const isSharp = Math.random() < sharpProbability;
	const color = isSharp 
		? sharpColors[Math.floor(Math.random() * sharpColors.length)]
		: colorVariations[Math.floor(Math.random() * colorVariations.length)];
	
	const opacity = isSharp
		? 0.8 + Math.random() * 0.2 // Much brighter
		: opacityRange.min + Math.random() * (opacityRange.max - opacityRange.min);

	return {
		// ... other properties ...
		isSharp,
		color,
		opacity
	};
}

function drawSharpParticle(particle: Particle) {
	if (!ctx) return;

	// Sharper gradient - less soft, more focused
	const gradient = ctx.createRadialGradient(
		particle.x, particle.y, 0,
		particle.x, particle.y, particle.radius
	);

	const alpha = Math.floor(particle.opacity * 255).toString(16).padStart(2, '0');
	
	// Sharper falloff - brighter center, quicker fade
	gradient.addColorStop(0, `${particle.color}${alpha}`);
	gradient.addColorStop(0.3, `${particle.color}${Math.floor(particle.opacity * 0.8 * 255).toString(16).padStart(2, '0')}`);
	gradient.addColorStop(0.7, `${particle.color}${Math.floor(particle.opacity * 0.4 * 255).toString(16).padStart(2, '0')}`);
	gradient.addColorStop(1, `${particle.color}00`);

	ctx.fillStyle = gradient;
	ctx.beginPath();
	ctx.arc(particle.x, particle.y, particle.radius, 0, Math.PI * 2);
	ctx.fill();

	// Add bright center highlight for extra sharpness
	const centerRadius = particle.radius * 0.4;
	const centerGradient = ctx.createRadialGradient(
		particle.x, particle.y, 0,
		particle.x, particle.y, centerRadius
	);
	
	centerGradient.addColorStop(0, `#FFFFFF${Math.floor(Math.min(1.0, particle.opacity * 1.1) * 255).toString(16).padStart(2, '0')}`);
	centerGradient.addColorStop(1, `${particle.color}00`);

	ctx.fillStyle = centerGradient;
	ctx.beginPath();
	ctx.arc(particle.x, particle.y, centerRadius, 0, Math.PI * 2);
	ctx.fill();
}

Design decisions:

  • Low probability: Only 8% are sharp, making them special
  • Very bright colors: White and very light pastels
  • High opacity: 0.8-1.0 vs 0.2-0.6 for normal particles
  • Sharper gradient: Quicker falloff creates focused appearance
  • Center highlight: White core adds extra brightness

Q: How do I control particle speed?

A: Adjust the speed constant to control movement velocity:

const speed = 0.1; // Lower = slower movement

function createParticle(): Particle {
	return {
		// ...
		vx: (Math.random() - 0.5) * speed,
		vy: (Math.random() - 0.5) * speed,
		// ...
	};
}

Speed guidelines:

  • 0.05-0.1: Very slow, meditative movement
  • 0.1-0.3: Gentle, calm movement (recommended)
  • 0.3-0.5: Moderate, noticeable movement
  • 0.5+: Fast, energetic movement

Lower speeds create a more subtle, ambient effect that doesn't distract from content.

Next Steps

In Part 3, we'll cover advanced topics like:

  • Performance optimizations
  • Svelte integration patterns
  • Configuration and customization
  • Production-ready considerations
  • Accessibility considerations

Stay tuned for the final part!