The idea
I've been wanting to add something visual to my portfolio for a while. Not a static screenshot gallery — something people can actually interact with. Those fancy WebGL demos you see on Awwwards sites always looked cool to me but I never really understood how they worked under the hood.
Turns out most of them are simpler than they look. It's just math and a render loop. So I sat down and built 12 of them. No Three.js, no GSAP, no animation libraries. Just the Canvas 2D API, some CSS, and requestAnimationFrame.
Some of these took 30 minutes. Some took way longer than I'd like to admit.
How every effect works
They all follow the same skeleton. A React component, a useEffect, a canvas ref, and a loop:
useEffect(() => { const canvas = canvasRef.current; const ctx = canvas.getContext('2d'); let animationId: number; function loop() { // update numbers // draw things animationId = requestAnimationFrame(loop); } loop(); return () => cancelAnimationFrame(animationId); }, []);
That's really all there is to it. Every animation is just "update some numbers, draw some shapes, repeat 60 times a second." The interesting part is choosing which numbers to change and how.
I pulled out a few shared helpers — a lerp function, color conversion stuff, a height constant — into a utils.ts file so I wasn't copy-pasting the same math everywhere.
Liquid Glass
This one doesn't even use Canvas. Pure CSS.
backdrop-filter: blur() does the heavy lifting. You put a semi-transparent card over some content, blur the background, and it looks like frosted glass. Add a radial gradient that follows the cursor for a specular highlight, and tilt the card with transform: perspective() rotateX() rotateY() based on mouse position.
The tilt math is embarrassingly simple:
const rotateX = ((mouseY - centerY) / height) * 15; const rotateY = ((mouseX - centerX) / width) * -15;
I expected backdrop-filter to be janky but it was actually smooth. Even on my older machine. Browsers have gotten pretty good at this.
Magnetic Cursor
A grid of dots that get pulled toward your mouse. This was my first Canvas effect and it taught me the single most important performance lesson of this whole project.
My first version was creating new objects for every dot on every frame. 400 dots, 60 fps, that's 24,000 allocations per second. The garbage collector was thrashing and the animation was stuttering.
Fix was obvious in hindsight — pre-allocate everything and mutate in place:
// don't do this dots.map(d => ({ ...d, x: d.x + pull })) // do this instead for (let i = 0; i < dots.length; i++) { dots[i].x += pull; }
The magnetic pull is inverse-square distance. Nothing fancy. But it feels really satisfying when you move the cursor around and the dots follow.
Metaball Fusion
This was probably the hardest one. Metaballs are those blobby organic shapes that merge together like liquid mercury when they get close.
The algorithm is called Marching Squares. You set up a grid, calculate a field strength at each point based on distance to each blob, then draw contour lines where the field crosses a threshold. The field function is:
strength = radius² / ((x - blobX)² + (y - blobY)²)
When two blobs overlap, their fields add up and the contour wraps around both. That's what creates the smooth merging.
Performance was tight on this one. Evaluating the field at every grid point for every blob is O(grid × blobs). I kept the grid at about 2px resolution and limited blob count. It works but there's not a lot of room to spare.
Gravity Text
Render text to canvas, scan the pixels, turn every non-transparent pixel into a particle with physics. Click to explode, gravity pulls everything down, particles bounce off walls.
The bounce is just vy *= -0.7 when a particle hits the floor. The 0.7 gives it natural damping — each bounce is lower than the last. After a few seconds the particles lerp back to their original positions and the text reforms.
This was the most fun to build. Something about watching text explode and reassemble is deeply satisfying.
Aurora Borealis
Northern lights. Layer 5-6 translucent wave bands with different sine frequencies, amplitudes, and colors. Drift them horizontally over time. Let the cursor position affect the "wind."
const y = baseY + Math.sin(x * freq1 + time * speed1) * amp1 + Math.sin(x * freq2 + time * speed2) * amp2;
The stars in the background are random white dots with oscillating alpha for a twinkle effect. Dead simple but it really sells the whole scene.
Morphing Shapes
Shapes that smoothly morph between a star, circle, heart, and hexagon. The technique is path interpolation — define each shape as a series of points, then lerp between them.
The catch that tripped me up: both shapes need the same number of points. You can't lerp a 64-point circle to a 10-point star. So I resample all shapes to the same point count first.
The neon glow is just ctx.shadowColor and ctx.shadowBlur. Canvas shadow is basically free glow. I use it in almost every effect now.
Particle Vortex
2500 particles swirling in a vortex. This is where I started using Float32Array instead of regular objects.
// stride layout: [x, y, vx, vy, angle, radius, speed, hue] const particles = new Float32Array(NUM_PARTICLES * STRIDE);
The vortex is polar coordinates — each particle has an angle that increases (rotation) and a radius that oscillates (breathing). Convert to cartesian for drawing. Color is speed-based: faster = hotter.
The typed array made a noticeable difference. Not huge, but enough that I could push the particle count higher without dropping frames.
Glitch Distortion
Cyberpunk RGB channel splitting. Render text, grab the ImageData, shift the red channel left and blue channel right by a few pixels. Add random displaced rectangles and horizontal scan lines on top.
The cursor controls intensity — closer to the text means more glitch. Move away and it calms down. It's a small detail but it makes the whole thing feel responsive.
The harder ones
After the first 8 I wanted to try some more complex stuff.
Matrix Rain
The classic. Columns of falling katakana characters with green neon glow.
The best trick in this one is the trail effect. Instead of clearing the canvas every frame, draw a semi-transparent black rectangle:
ctx.fillStyle = 'rgba(0, 0, 0, 0.05)'; ctx.fillRect(0, 0, width, height);
Old characters fade out gradually. That's the entire Matrix trail effect. One line of code.
Columns near the cursor fall 3x faster. It's just a distance check on the X axis. Simple but it adds a lot of interactivity.
Warp Speed Tunnel
Hyperspace starfield. 1500 stars with 3D-to-2D projection:
const screenX = (x / z) * focalLength + vanishX; const screenY = (y / z) * focalLength + vanishY;
Each frame, z decreases (star comes toward you). When z hits zero, respawn at the back. Draw a line from previous screen position to current for the warp streak.
The mouse controls the vanishing point. Move cursor left, the whole tunnel steers left. The lerp with a small factor (0.05) makes the steering feel smooth and weighty instead of twitchy. That was a happy accident — I originally had it at 1.0 (instant follow) and it felt terrible.
Fluid Smoke
Particle-based smoke that follows your cursor. Each particle has position, velocity, life, hue, and size. Spawns at cursor on drag, drifts upward, expands, fades out.
The key visual trick is globalCompositeOperation = 'lighter'. It makes overlapping particles add their colors together, creating bright spots where trails cross. Without it the smoke looks flat and boring.
Color cycles through the full hue spectrum over about 10 seconds. Keep dragging and you get rainbow smoke trails. Click for a radial burst of 100 particles.
Water Ripple
My favorite. A water surface simulation using a double-buffer height field.
Two arrays. Every frame, calculate the new height at each point from its four neighbors:
newHeight[i] = ( (current[i-1] + current[i+1] + current[i-width] + current[i+width]) / 2 - previous[i] ) * damping;
Swap buffers. That's the whole simulation. The damping (0.98) makes ripples fade naturally.
For rendering I calculate displacement from height differences between neighboring cells and use that to shift colors — bright where waves converge (caustics), dark where they diverge. Random small disturbances every 30-60 frames keep the surface alive when you're not touching it.
This runs at half resolution and gets scaled up. You honestly can't tell the difference but it's 4x less computation.
Things I learned the hard way
Semi-transparent overlays are magic. Instead of clearing the canvas, draw rgba(0,0,0,0.05) and you get motion trails for free. I use this in like 8 of the 12 effects.
Pre-allocate everything. No object creation in render loops. Typed arrays for big particle systems. Object pools for things that spawn and die.
Half-resolution is fine. Water ripple runs at half res, scaled up. Nobody notices. 4x performance gain for free.
Canvas shadow = free glow. ctx.shadowColor + ctx.shadowBlur before drawing anything. Not the most performant thing ever but for demos it's more than good enough.
ResizeObserver over window.resize. More reliable, fires less, gives you the container size directly.
lerp with small factors feels good. lerp(current, target, 0.05) for smooth following. lerp(current, target, 1.0) for instant snapping. Most of the "feel" of these effects comes from choosing the right lerp factor.
How it's all wired up
The architecture is data-driven. One data file defines all 12 effects (slug, title, description, tech stack, SEO stuff). The listing page reads that array and renders cards. Detail pages use generateStaticParams for static generation. Each effect component loads via next/dynamic with ssr: false since Canvas doesn't exist on the server.
Adding a new effect is: create the component, add an entry to the data file, add the dynamic import. Routing and SEO handle themselves.
What I'd change
If I did this again I'd extract the canvas setup + resize observer + animation loop into a shared hook. Every effect has the same 20 lines of boilerplate. I also should have added touch support from the start instead of bolting it on after.
For the heavier effects (fluid smoke, water ripple) WebGL would let me push way harder on particle counts and grid resolution. But Canvas 2D was the right call for learning — you see exactly what's happening, no shader abstraction layer in the way.
Go play with them
All 12 are live: /css-effects
Move your mouse around, click stuff, drag things. They're all interactive. Code is open source if you want to look at how any of them work.