Creating Bokeh Particles with Canvas in Svelte - Part 3: Integration and Optimization

Performance tips, Svelte integration patterns, and production-ready considerations

Introduction

In Part 1 and Part 2, we built the core particle system. Now let's integrate it properly with Svelte, optimize performance, and make it production-ready.

Table of Contents

Q: How do I properly integrate Canvas with Svelte's lifecycle?

A: Svelte's lifecycle hooks are crucial for proper cleanup and SSR compatibility:

<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 resizeObserver: ResizeObserver | null = null;

	onMount(() => {
		// Always check for browser environment
		if (!browser) return;

		initCanvas();
		animate();

		// Handle resize efficiently
		resizeObserver = new ResizeObserver(() => {
			initCanvas();
		});
		resizeObserver.observe(canvas);

		// Cleanup function
		return () => {
			if (animationFrameId) {
				cancelAnimationFrame(animationFrameId);
			}
			if (resizeObserver) {
				resizeObserver.disconnect();
			}
		};
	});

	onDestroy(() => {
		// Extra safety: cleanup if component unmounts
		if (animationFrameId) {
			cancelAnimationFrame(animationFrameId);
		}
		if (resizeObserver) {
			resizeObserver.disconnect();
		}
	});
</script>

Key points:

  • browser check: Essential for SSR - Canvas doesn't exist on the server
  • Return cleanup in onMount: Svelte calls this when the component is destroyed
  • onDestroy as backup: Extra safety for edge cases
  • ResizeObserver: More efficient than window resize events

Q: How do I handle high-DPI displays correctly?

A: High-DPI (Retina) displays need special handling to avoid blurry rendering:

function initCanvas() {
	if (!canvas || !browser) return;

	ctx = canvas.getContext('2d');
	if (!ctx) return;

	const rect = canvas.getBoundingClientRect();
	const dpr = window.devicePixelRatio || 1;

	// Set actual canvas size in pixels
	canvas.width = rect.width * dpr;
	canvas.height = rect.height * dpr;

	// Scale context back to CSS pixels
	ctx.scale(dpr, dpr);

	// Now all drawing uses CSS pixel coordinates
	// But renders at device pixel resolution
}

Why this works:

  • CSS size: Canvas element uses CSS pixels (e.g., 800px width)
  • Internal resolution: Canvas buffer uses device pixels (e.g., 1600px on 2x display)
  • Scaling context: Drawing at CSS coordinates but rendering at full resolution
  • Result: Crisp, sharp rendering on all displays

Q: How can I optimize performance?

A: Several techniques can improve performance:

1. Reduce Redraws

let lastTime = 0;
const targetFPS = 60;
const frameInterval = 1000 / targetFPS;

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

	// Throttle to target FPS if needed
	if (currentTime - lastTime < frameInterval) {
		animationFrameId = requestAnimationFrame(animate);
		return;
	}

	lastTime = currentTime;
	// ... rest of animation code
}

2. Use Object Pools (for many particles)

// Reuse particle objects instead of creating new ones
const particlePool: Particle[] = [];

function getParticle(): Particle {
	if (particlePool.length > 0) {
		return particlePool.pop()!;
	}
	return createNewParticle();
}

function recycleParticle(particle: Particle) {
	particlePool.push(particle);
}

3. Conditional Rendering

// Only animate when tab is visible
let isVisible = true;

onMount(() => {
	document.addEventListener('visibilitychange', () => {
		isVisible = !document.hidden;
		if (isVisible) {
			animate();
		}
	});
});

function animate() {
	if (!isVisible) return;
	// ... animation code
}

Q: How do I make the component reusable and configurable?

A: Use Svelte props for configuration:

<script lang="ts">
	interface Props {
		particleCount?: number;
		minRadius?: number;
		maxRadius?: number;
		speed?: number;
		colors?: string[];
		opacityRange?: { min: number; max: number };
	}

	let {
		particleCount = 50,
		minRadius = 2,
		maxRadius = 8,
		speed = 0.3,
		colors = ['#C47CCF', '#D18FE0', '#B86AB8'],
		opacityRange = { min: 0.2, max: 0.6 }
	}: Props = $props();
</script>

Benefits:

  • Flexible: Different pages can use different settings
  • Type-safe: TypeScript ensures correct usage
  • Defaults: Sensible defaults make it easy to use
  • Reactive: Changes to props automatically update behavior

Q: How do I style the canvas to overlay content?

A: Use CSS positioning to layer the canvas:

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

<style>
	.particles-canvas {
		position: absolute;
		top: 0;
		left: 0;
		width: 100%;
		height: 100%;
		pointer-events: none;  /* Allow clicks to pass through */
		z-index: 1;            /* Above background, below content */
	}
</style>

In the parent component:

<section class="hero-section">
	<BokehParticles />
	<div class="hero-content">
		<!-- Content with z-index: 2 -->
	</div>
</section>

<style>
	.hero-section {
		position: relative;  /* Required for absolute positioning */
	}
	
	.hero-content {
		position: relative;
		z-index: 2;  /* Above particles */
	}
</style>

Q: How do I handle color variations programmatically?

A: Generate color variations from a base color:

function generateColorVariations(baseColor: string, count: number): string[] {
	// Parse hex color
	const hex = baseColor.replace('#', '');
	const r = parseInt(hex.substr(0, 2), 16);
	const g = parseInt(hex.substr(2, 2), 16);
	const b = parseInt(hex.substr(4, 2), 16);

	const variations: string[] = [];
	
	for (let i = 0; i < count; i++) {
		// Create lighter and darker variations
		const factor = (i / count) * 0.4 - 0.2; // -0.2 to 0.2
		const newR = Math.max(0, Math.min(255, r + factor * 255));
		const newG = Math.max(0, Math.min(255, g + factor * 255));
		const newB = Math.max(0, Math.min(255, b + factor * 255));
		
		variations.push(
			`#${Math.floor(newR).toString(16).padStart(2, '0')}${Math.floor(newG).toString(16).padStart(2, '0')}${Math.floor(newB).toString(16).padStart(2, '0')}`
		);
	}
	
	return variations;
}

Q: What about accessibility?

A: Canvas content isn't accessible by default. Here's how to handle it:

<canvas
	bind:this={canvas}
	class="particles-canvas"
	aria-hidden="true"
	role="presentation"
></canvas>

Why aria-hidden="true"?

  • Decorative only: Particles are visual decoration, not content
  • Screen readers: Prevents screen readers from trying to interpret the canvas
  • Focus: pointer-events: none already prevents interaction
  • Semantic: role="presentation" reinforces decorative nature

Q: How do I test the component?

A: Create a dedicated test page:

<!-- src/routes/test-particles/+page.svelte -->
<script lang="ts">
	import BokehParticles from '$lib/BokehParticles.svelte';
</script>

<section class="hero-section">
	<BokehParticles />
	<div class="hero-content">
		<h1>Particles Test</h1>
		<p>Test your particle system here</p>
	</div>
</section>

Benefits:

  • Isolated testing: Test without affecting production pages
  • Easy iteration: Quick changes and visual feedback
  • Performance testing: Check FPS and memory usage
  • Responsive testing: Test at different viewport sizes

Q: How do I configure all particle settings in one place?

A: Centralize all configuration constants at the top of your component:

// Particle system configuration
const particleCount = 50;
const minRadius = 2;
const maxRadius = 8;
const speed = 0.1; // Movement speed (lower = slower)

// Visual properties
const opacityRange = { min: 0.2, max: 0.6 };
const colorVariations = ['#C47CCF', '#D18FE0', '#B86AB8', '#E5A8F0', '#A85AA8'];

// Special effects
const glowProbability = 0.25; // 25% get glow effect
const sharpProbability = 0.08; // 8% are sharp and bright
const sharpColors = ['#FFFFFF', '#F5E6FF', '#E8D5FF', '#FFE5FF'];

Benefits of centralized config:

  • Easy tweaking: Adjust all parameters in one place
  • Documentation: Comments explain each setting
  • Experimentation: Quickly test different values
  • Maintainability: Clear overview of system behavior

Production Checklist

Before deploying, ensure:

  • SSR compatibility: All Canvas code wrapped in browser checks
  • Performance: Runs smoothly at 60fps on target devices
  • Responsive: Works on mobile, tablet, and desktop
  • Cleanup: All event listeners and observers are removed
  • Accessibility: Proper ARIA attributes
  • Error handling: Graceful degradation if Canvas isn't supported
  • Memory: No memory leaks (check with DevTools)
  • Visual balance: Glow and sharp particles enhance without overwhelming

Advanced Visual Effects Summary

Our complete particle system now includes:

  1. Soft Bokeh particles: The majority with gentle, soft gradients
  2. Glowing particles: 25% with rotating, pulsing glow effects
  3. Sharp particles: 8% rare, bright, focused particles for contrast
  4. Smooth animation: Slow, gentle movement that doesn't distract
  5. Color variations: Cohesive color palette with bright accents

Visual hierarchy:

  • Base layer: Soft Bokeh particles create ambient atmosphere
  • Accent layer: Glowing particles add subtle interest
  • Highlight layer: Sharp particles create focal points

This layered approach creates depth and visual interest while maintaining a calm, professional appearance.

Conclusion

You now have a complete, production-ready particle system with advanced visual effects! The key takeaways:

  1. Canvas is powerful for complex animations
  2. Proper lifecycle management prevents memory leaks
  3. Performance optimization ensures smooth experience
  4. Visual layering creates depth and interest
  5. Configuration centralization makes tweaking easy
  6. Accessibility shouldn't be forgotten

The particle system adds a beautiful, subtle animation with glowing accents and bright highlights that enhance your site without being distracting. The combination of soft Bokeh, rotating glows, and sharp highlights creates a sophisticated, modern aesthetic.

Happy coding!

Further Reading and References

Bokeh Effect

HTML5 Canvas

Particle Systems and Animation

Performance Optimization

Svelte and Canvas Integration