Remember that mesmerizing green rain of characters from The Matrix? Today, we’re going to recreate this iconic effect using modern web technologies, pushing the boundaries of what’s possible with vanilla JavaScript and the Canvas API. We’ll build a high-performance version that maintains 60fps with thousands of falling characters, complete with interactive mouse effects and customizable parameters.
What We’re Building
We’ll create a full-screen Matrix rain effect with:
- Thousands of falling characters with realistic physics
- Interactive mouse effects that brighten nearby characters
- Customizable speed, density, and glow effects
- Performance optimizations using OffscreenCanvas
- Real-time FPS monitoring
- Live controls to tweak the effect
Understanding the Core Concepts
Before diving into code, let’s understand the key technologies we’ll use:
OffscreenCanvas
This API allows us to render graphics in a separate thread, preventing our animation from blocking the main thread. This is crucial for maintaining smooth 60fps performance.
Typed Arrays
Instead of regular JavaScript arrays, we’ll use Float32Array and Uint16Array for better memory efficiency and faster access when dealing with thousands of data points.
RequestAnimationFrame
This browser API ensures our animation runs at the optimal frame rate, synchronized with the display’s refresh rate.
Setting Up the HTML Structure
Let’s start with our HTML foundation:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
| <!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Matrix Digital Rain</title>
<style>
body {
margin: 0;
padding: 0;
background: #000;
overflow: hidden;
cursor: none; /* We'll create a custom cursor */
}
#canvas {
display: block;
width: 100vw;
height: 100vh;
/* Crisp pixel rendering for that retro feel */
image-rendering: pixelated;
image-rendering: crisp-edges;
}
/* Custom cursor with Matrix-style glow */
#cursor {
position: absolute;
width: 20px;
height: 20px;
border: 2px solid #00ff00;
border-radius: 50%;
pointer-events: none;
z-index: 1000;
box-shadow: 0 0 20px #00ff00;
mix-blend-mode: screen;
}
/* Control panel styling */
#controls {
position: absolute;
top: 10px;
right: 10px;
background: rgba(0, 0, 0, 0.8);
padding: 15px;
border: 1px solid #00ff00;
border-radius: 5px;
font-family: monospace;
color: #00ff00;
font-size: 12px;
z-index: 10;
}
#controls label {
display: block;
margin-bottom: 5px;
}
#controls input[type="range"] {
width: 100%;
margin-bottom: 10px;
}
</style>
</head>
<body>
<canvas id="canvas"></canvas>
<div id="cursor"></div>
<div id="controls">
<label>
Speed: <span id="speedValue">1</span>
<input type="range" id="speed" min="0.1" max="3" step="0.1" value="1">
</label>
<label>
Density: <span id="densityValue">0.95</span>
<input type="range" id="density" min="0.8" max="0.99" step="0.01" value="0.95">
</label>
<label>
Glow Intensity: <span id="glowValue">0.5</span>
<input type="range" id="glow" min="0" max="1" step="0.1" value="0.5">
</label>
<label>
<input type="checkbox" id="mouseInteraction" checked> Mouse Interaction
</label>
<label>
<input type="checkbox" id="useOffscreen" checked> Use OffscreenCanvas
</label>
<div>FPS: <span id="fps">0</span></div>
</div>
<script type="module" src="matrix.js"></script>
</body>
</html>
|
Building the Matrix Rain Class
Now for the JavaScript magic. We’ll create a modular MatrixRain class that handles everything:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
| // matrix.js
class MatrixRain {
constructor(canvas) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d', {
alpha: false, // No transparency = better performance
desynchronized: true // Reduce latency between JS and GPU
});
// Configuration object for easy customization
this.config = {
speed: 1,
density: 0.95,
glowIntensity: 0.5,
mouseInteraction: true,
useOffscreen: true,
fontSize: 14,
// Mix of katakana characters and alphanumerics for authenticity
characters: 'アイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワン' +
'0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ',
colors: {
primary: '#00ff00',
secondary: '#00cc00',
tertiary: '#009900',
trail: '#003300'
}
};
// Mouse tracking for interactive effects
this.mouse = { x: 0, y: 0, radius: 150 };
// Performance monitoring
this.fps = 0;
this.frameCount = 0;
this.lastTime = performance.now();
this.init();
}
init() {
this.resize();
// Calculate how many columns of characters we can fit
this.columns = Math.floor(this.width / this.config.fontSize);
// Initialize typed arrays for maximum performance
this.drops = new Float32Array(this.columns); // Y position
this.brightness = new Float32Array(this.columns); // Character brightness
this.speeds = new Float32Array(this.columns); // Fall speed
this.characters = new Uint16Array(this.columns); // Character index
// Initialize each column with random values
for (let i = 0; i < this.columns; i++) {
this.reset(i);
}
// Pre-calculate character array for faster access
this.charCodes = Array.from(this.config.characters);
// Setup canvas text rendering
this.ctx.font = `${this.config.fontSize}px monospace`;
this.ctx.textAlign = 'center';
this.ctx.textBaseline = 'middle';
this.setupEventListeners();
this.setupOffscreenCanvas();
}
reset(column) {
// Start drops at random positions above the screen
this.drops[column] = Math.random() * -100;
this.brightness[column] = Math.random();
this.speeds[column] = 0.5 + Math.random() * 1.5;
this.characters[column] = Math.floor(Math.random() * this.charCodes.length);
}
resize() {
// Handle high-DPI displays properly
const dpr = window.devicePixelRatio || 1;
this.width = window.innerWidth;
this.height = window.innerHeight;
this.canvas.width = this.width * dpr;
this.canvas.height = this.height * dpr;
this.canvas.style.width = `${this.width}px`;
this.canvas.style.height = `${this.height}px`;
this.ctx.scale(dpr, dpr);
// Reinitialize if already running
if (this.drops) {
this.init();
}
}
setupOffscreenCanvas() {
if (!this.config.useOffscreen || !window.OffscreenCanvas) {
this.offscreenCanvas = null;
return;
}
try {
this.offscreenCanvas = new OffscreenCanvas(this.width, this.height);
this.offscreenCtx = this.offscreenCanvas.getContext('2d', {
alpha: false,
desynchronized: true
});
// Mirror the main canvas settings
this.offscreenCtx.font = `${this.config.fontSize}px monospace`;
this.offscreenCtx.textAlign = 'center';
this.offscreenCtx.textBaseline = 'middle';
} catch (e) {
console.warn('OffscreenCanvas not available:', e);
this.offscreenCanvas = null;
}
}
setupEventListeners() {
// Track mouse movement
document.addEventListener('mousemove', (e) => {
this.mouse.x = e.clientX;
this.mouse.y = e.clientY;
// Update custom cursor position
const cursor = document.getElementById('cursor');
cursor.style.left = `${e.clientX - 10}px`;
cursor.style.top = `${e.clientY - 10}px`;
});
// Handle window resizing
window.addEventListener('resize', () => this.resize());
// Wire up control panel
this.setupControls();
}
setupControls() {
const controls = {
speed: document.getElementById('speed'),
density: document.getElementById('density'),
glow: document.getElementById('glow'),
mouseInteraction: document.getElementById('mouseInteraction'),
useOffscreen: document.getElementById('useOffscreen')
};
// Speed control
controls.speed.addEventListener('input', (e) => {
this.config.speed = parseFloat(e.target.value);
document.getElementById('speedValue').textContent = e.target.value;
});
// Density control (how often characters respawn)
controls.density.addEventListener('input', (e) => {
this.config.density = parseFloat(e.target.value);
document.getElementById('densityValue').textContent = e.target.value;
});
// Glow intensity
controls.glow.addEventListener('input', (e) => {
this.config.glowIntensity = parseFloat(e.target.value);
document.getElementById('glowValue').textContent = e.target.value;
});
// Toggle mouse interaction
controls.mouseInteraction.addEventListener('change', (e) => {
this.config.mouseInteraction = e.target.checked;
});
// Toggle OffscreenCanvas
controls.useOffscreen.addEventListener('change', (e) => {
this.config.useOffscreen = e.target.checked;
this.setupOffscreenCanvas();
});
}
update(deltaTime) {
// Make animation frame-rate independent
const effectiveSpeed = this.config.speed * deltaTime * 0.05;
for (let i = 0; i < this.columns; i++) {
// Update drop position
this.drops[i] += this.speeds[i] * effectiveSpeed;
// Reset when drop goes off screen
if (this.drops[i] * this.config.fontSize > this.height) {
if (Math.random() > this.config.density) {
this.reset(i);
} else {
this.drops[i] = -1;
}
}
// Randomly change characters for that "glitch" effect
if (Math.random() > 0.98) {
this.characters[i] = Math.floor(Math.random() * this.charCodes.length);
}
// Mouse interaction: brighten nearby characters
if (this.config.mouseInteraction) {
const x = i * this.config.fontSize + this.config.fontSize / 2;
const y = this.drops[i] * this.config.fontSize;
const dist = Math.hypot(x - this.mouse.x, y - this.mouse.y);
if (dist < this.mouse.radius) {
const force = 1 - (dist / this.mouse.radius);
this.brightness[i] = Math.min(1, this.brightness[i] + force * 0.5);
this.speeds[i] = Math.min(3, this.speeds[i] + force * 0.2);
}
}
// Fade brightness over time
this.brightness[i] *= 0.96;
}
}
render() {
// Use offscreen canvas if available
const ctx = this.offscreenCanvas ? this.offscreenCtx : this.ctx;
// Create trail effect by drawing semi-transparent black over previous frame
ctx.fillStyle = 'rgba(0, 0, 0, 0.05)';
ctx.fillRect(0, 0, this.width, this.height);
// Draw each column of characters
for (let i = 0; i < this.columns; i++) {
const x = i * this.config.fontSize + this.config.fontSize / 2;
const y = this.drops[i] * this.config.fontSize;
// Skip if off screen
if (y < 0 || y > this.height) continue;
const char = this.charCodes[this.characters[i]];
const brightness = this.brightness[i];
// Set color based on brightness
if (brightness > 0.8) {
// Leading character - bright white with glow
if (this.config.glowIntensity > 0) {
ctx.shadowBlur = 20 * this.config.glowIntensity;
ctx.shadowColor = this.config.colors.primary;
}
ctx.fillStyle = '#ffffff';
} else if (brightness > 0.6) {
ctx.shadowBlur = 0;
ctx.fillStyle = this.config.colors.primary;
} else if (brightness > 0.4) {
ctx.shadowBlur = 0;
ctx.fillStyle = this.config.colors.secondary;
} else if (brightness > 0.2) {
ctx.shadowBlur = 0;
ctx.fillStyle = this.config.colors.tertiary;
} else {
ctx.shadowBlur = 0;
ctx.fillStyle = this.config.colors.trail;
}
ctx.fillText(char, x, y);
// Draw fading trail of characters
for (let j = 1; j < 20; j++) {
const trailY = y - j * this.config.fontSize;
if (trailY < 0) break;
const trailBrightness = brightness * Math.pow(0.85, j);
if (trailBrightness < 0.1) break;
// Convert brightness to hex alpha value
const alpha = Math.floor(trailBrightness * 255).toString(16).padStart(2, '0');
ctx.fillStyle = `#00ff00${alpha}`;
ctx.fillText(char, x, trailY);
}
}
// Copy from offscreen canvas if we're using it
if (this.offscreenCanvas) {
this.ctx.clearRect(0, 0, this.width, this.height);
this.ctx.drawImage(this.offscreenCanvas, 0, 0);
}
this.updateFPS();
}
updateFPS() {
this.frameCount++;
const currentTime = performance.now();
if (currentTime - this.lastTime >= 1000) {
this.fps = this.frameCount;
this.frameCount = 0;
this.lastTime = currentTime;
document.getElementById('fps').textContent = this.fps;
}
}
animate() {
let lastTimestamp = 0;
const frame = (timestamp) => {
const deltaTime = timestamp - lastTimestamp;
lastTimestamp = timestamp;
this.update(deltaTime);
this.render();
requestAnimationFrame(frame);
};
requestAnimationFrame(frame);
}
}
// Initialize and start the effect
const canvas = document.getElementById('canvas');
const matrix = new MatrixRain(canvas);
matrix.animate();
// Export for potential module use
export { MatrixRain };
|
Let’s explore the key performance optimizations that make this effect run smoothly:
1. OffscreenCanvas Magic
OffscreenCanvas allows us to perform rendering operations without blocking the main thread. This is especially beneficial when dealing with thousands of draw calls:
1
2
3
4
5
6
| // Rendering happens on a separate canvas
this.offscreenCanvas = new OffscreenCanvas(width, height);
this.offscreenCtx = this.offscreenCanvas.getContext('2d');
// Then we copy the result to the main canvas
this.ctx.drawImage(this.offscreenCanvas, 0, 0);
|
2. Typed Arrays for Speed
JavaScript’s typed arrays provide direct memory access, making them much faster than regular arrays for numerical operations:
1
2
3
4
5
| // Regular array (slower)
this.drops = new Array(1000);
// Typed array (faster)
this.drops = new Float32Array(1000);
|
3. Smart Trail Rendering
Instead of clearing the entire canvas each frame (expensive), we draw a semi-transparent black rectangle. This creates the fading trail effect while being much more performant:
1
2
3
4
5
6
| // Expensive way: clear everything
ctx.clearRect(0, 0, width, height);
// Smart way: fade to black
ctx.fillStyle = 'rgba(0, 0, 0, 0.05)';
ctx.fillRect(0, 0, width, height);
|
4. Conditional Rendering
We only draw what’s visible:
1
| if (y < 0 || y > this.height) continue;
|
Interactive Features
The mouse interaction adds a dynamic element to the effect:
1
2
3
4
5
6
7
8
9
| // Calculate distance from mouse
const dist = Math.hypot(x - this.mouse.x, y - this.mouse.y);
if (dist < this.mouse.radius) {
// Brighten and speed up nearby characters
const force = 1 - (dist / this.mouse.radius);
this.brightness[i] = Math.min(1, this.brightness[i] + force * 0.5);
this.speeds[i] = Math.min(3, this.speeds[i] + force * 0.2);
}
|
Browser Compatibility
This effect uses several modern APIs:
- OffscreenCanvas: Chrome 69+, Firefox 105+, Safari 16.4+
- Desynchronized Context: Chrome 75+, Firefox 105+
- Core Features: All modern browsers (2020+)
The code includes fallbacks for browsers that don’t support OffscreenCanvas, ensuring the effect still works (albeit with potentially lower performance).
Creative Variations
Once you have the basic effect working, try these variations:
1. Rainbow Matrix
Replace the green color scheme with a rainbow gradient:
1
2
| const hue = (brightness * 120 + Date.now() * 0.01) % 360;
ctx.fillStyle = `hsl(${hue}, 100%, ${brightness * 50}%)`;
|
2. Binary Rain
Use only 0s and 1s for a pure digital look:
3. Emoji Rain
Why not? 🎉
1
| characters: '😀😎🚀💻🎨🎯🔥⚡️✨🌟'
|
4. 3D Perspective
Add depth by scaling characters based on their column:
1
2
| const scale = 0.5 + (i / this.columns) * 0.5;
ctx.font = `${this.config.fontSize * scale}px monospace`;
|
- Reduce Character Count: Fewer columns = better performance
- Disable Glow: The shadow blur effect is expensive
- Lower Trail Length: Reduce the trail loop iterations
- Bigger Font Size: Fewer characters to render
- Disable Mouse Interaction: One less calculation per frame
Going Further
This effect is a great starting point for more complex visualizations:
- Audio Reactive: Connect to the Web Audio API to make characters dance to music
- WebGL Version: For even better performance with millions of characters
- Particle Physics: Add collision detection between characters
- Network Data: Visualize real-time data streams
- Game Integration: Use as a background for a cyberpunk-themed game
Conclusion
We’ve created a high-performance Matrix rain effect that showcases the power of modern Canvas APIs. The combination of OffscreenCanvas, typed arrays, and smart rendering techniques allows us to animate thousands of characters at 60fps while maintaining interactivity.
The modular structure makes it easy to customize and extend. Whether you want to change the character set, add new visual effects, or integrate it into a larger project, the foundation is solid and performant.
Feel free to experiment with the live demo and tweak the parameters to create your own unique version of this iconic effect!