How I Built a Procedural Topographical Timeline Portfolio using Canvas 2D, React & GSAP
When designing a developer portfolio, standard grid layouts and vertical timeline cards can often feel repetitive. I wanted to build something that felt alive, immersive, and visually unique—a "Topographical Timeline" where a developer's career journey is depicted as a glowing trail across a procedurally generated mountain landscape.
In this article, I will break down the engineering architecture behind this project: how to generate procedural terrain contours in the browser, optimize HTML5 Canvas performance with offscreen caching, draw multi-layered glowing neon paths, and sync scroll progress with interactive HTML overlay cards.
🏗 Architecture & Layering Strategy
To achieve 60 FPS performance while keeping interactive UI elements accessible, the application uses a hybrid 3-layer architecture:
┌─────────────────────────────────────────────────────────┐
│ Layer 3: Interactive HTML/CSS Cards (Fixed Overlays) │
├─────────────────────────────────────────────────────────┤
│ Layer 2: Main Canvas (Glowing Bezier Trail & Bullet) │
├─────────────────────────────────────────────────────────┤
│ Layer 1: Offscreen Cached Canvas (Procedural Terrain) │
└─────────────────────────────────────────────────────────┘
-
Procedural Terrain Layer (Offscreen Canvas Snapshot): Renders multi-octave 2D elevation noise converted to elevation iso-contour vectors via
d3-contour. - Glowing Trail & Scroll Layer (Visible Canvas 2D): Clears and draws the cached terrain image, then draws a responsive cubic Bezier trail with multi-layered neon glow strokes and an animated scroll tracker bullet.
- Interactive Waypoint Layer (HTML/CSS Overlays): HTML cards positioned absolutely using pixel coordinates calculated by sampling points along the SVG Bezier path string.
⛰ 1. Generating Procedural Terrain with Simplex Noise & D3-Contour
Topographic maps represent height variations using contour lines (iso-lines). To create natural, continuous mountain terrain:
- We sample height values across a 2D grid using Simplex Noise.
- We combine multiple noise frequencies (octaves) so terrain has both large mountain peaks and subtle ridge details.
- We feed the 2D heightmap matrix into
d3-contourto compute vector contour coordinates.
Here is the core terrain generation function:
import { createNoise2D } from 'simplex-noise';
import { contours } from 'd3-contour';
export const buildTerrain = (width: number, height: number) => {
const terrainCanvas = document.createElement('canvas');
terrainCanvas.width = width;
terrainCanvas.height = height;
const context = terrainCanvas.getContext('2d');
if (!context) return terrainCanvas;
// Dark topographic base fill
context.fillStyle = '#121510';
context.fillRect(0, 0, width, height);
// 1. Generate Simplex Noise Grid
const resolution = 12; // Grid step size in pixels
const cols = Math.ceil(width / resolution) + 1;
const rows = Math.ceil(height / resolution) + 1;
const values = new Float64Array(cols * rows);
const noise2D = createNoise2D();
const noiseScale = 0.0025;
for (let y = 0; y < rows; y++) {
for (let x = 0; x < cols; x++) {
const worldX = x * resolution;
const worldY = y * resolution;
// Multi-octave noise for realistic elevation variation
const n1 = noise2D(worldX * noiseScale, worldY * noiseScale);
const n2 = noise2D(worldX * noiseScale * 2, worldY * noiseScale * 2) * 0.5;
const n3 = noise2D(worldX * noiseScale * 4, worldY * noiseScale * 4) * 0.25;
values[y * cols + x] = (n1 + n2 + n3 + 1.75) / 3.5; // Normalized ~[0, 1]
}
}
// 2. Extract Vector Iso-Lines using d3-contour
const contourGenerator = contours()
.size([cols, rows])
.thresholds(22);
const contourData = contourGenerator(Array.from(values));
// 3. Render Contour Vectors onto Offscreen Canvas
context.save();
context.lineWidth = 1.2;
contourData.forEach((contour, idx) => {
// Opacity scales with height elevation
const alpha = 0.05 + (idx / contourData.length) * 0.16;
context.strokeStyle = `rgba(140, 165, 120, ${alpha})`;
context.beginPath();
contour.coordinates.forEach((polygon) => {
polygon.forEach((ring) => {
ring.forEach(([x, y], i) => {
const px = x * resolution;
const py = y * resolution;
if (i === 0) context.moveTo(px, py);
else context.lineTo(px, py);
});
});
});
context.stroke();
});
context.restore();
return terrainCanvas;
};
⚡ 2. Performance Optimization: Offscreen Canvas Snapshot Caching
Generating Simplex Noise across thousands of grid points and computing 22 threshold contour polygons is CPU-intensive. If you run buildTerrain on every single scroll frame (requestAnimationFrame), frame rates will drop below 15 FPS.
The Solution:
-
On Window Resize: Compute
buildTerrain(width, height)once and store the offscreen canvas element in auseRef. -
On Scroll Update: Clear the main screen canvas and draw the cached offscreen snapshot instantly using
ctx.drawImage(snapshotRef.current.terrainCanvas, 0, 0).
const drawRoute = () => {
const canvas = canvasRef.current;
if (!canvas || !snapshotRef.current || !pathElementRef.current) return;
const context = canvas.getContext('2d');
if (!context) return;
// ⚡ 1. Clear visible canvas
context.clearRect(0, 0, canvas.width, canvas.height);
// ⚡ 2. Blit pre-rendered terrain image instantly (0 CPU noise calculations!)
context.drawImage(snapshotRef.current.terrainCanvas, 0, 0);
// ⚡ 3. Draw active scroll trail & glowing bullet on top
drawAuxiliaryRouteLines(context, pathElementRef.current);
drawActiveRouteLine(context, pathElementRef.current, scrollProgress);
drawScrollBullet(context, pathElementRef.current, scrollProgress);
};
This simple caching pattern keeps canvas rendering at a rock-solid 60 FPS regardless of screen size!
🛣 3. Creating the Glowing Trail & Multi-Layer Neon Effects
To give the route a futuristic, tactical HUD feel, the active trail isn't drawn with a single stroke. Instead, we render 4 overlapping line passes with varying stroke widths, opacities, and canvas shadow blurs:
const drawActiveRouteLine = (
context: CanvasRenderingContext2D,
path: SVGPathElement,
progress: number
) => {
if (progress <= 0) return;
const totalLength = path.getTotalLength();
const activeLength = totalLength * progress;
const step = 4;
context.save();
context.beginPath();
let first = true;
// Sample path points up to active scroll length
for (let l = 0; l <= activeLength; l += step) {
const pt = path.getPointAtLength(l);
if (first) {
context.moveTo(pt.x, pt.y);
first = false;
} else {
context.lineTo(pt.x, pt.y);
}
}
// Layered glowing stroke configurations
const lineConfigs = [
{ strokeStyle: 'rgba(202, 250, 92, 0.25)', lineWidth: 16, blur: 16 }, // Ambient Outer Glow
{ strokeStyle: 'rgba(202, 250, 92, 0.6)', lineWidth: 6, blur: 8 }, // Medium Glow
{ strokeStyle: '#caef5c', lineWidth: 2.5, blur: 2 }, // Core Neon Lime Line
{ strokeStyle: '#ffffff', lineWidth: 1.2, blur: 0 } // Sharp White Core Highlight
];
for (const config of lineConfigs) {
context.save();
context.strokeStyle = config.strokeStyle;
context.lineWidth = config.lineWidth;
context.lineCap = 'round';
context.lineJoin = 'round';
if (config.blur > 0) {
context.shadowColor = '#caef5c';
context.shadowBlur = config.blur;
}
context.stroke();
context.restore();
}
context.restore();
};
🎯 4. Syncing Scroll & Positioning HTML Waypoint Overlays
To map viewport scrolling to the route trail, we use GSAP ScrollTrigger:
- A wrapper
<main>container is given a large virtual scroll height (e.g.,min-h-[400vh]). - GSAP calculates a normalized
scrollProgressbetween0.0(top of page) and1.0(bottom of page). - We convert each milestone's
routeProgressPercentageinto exact(X, Y)screen pixel coordinates using an SVG Path's.getPointAtLength()API.
// Calculate exact pixel positions for each waypoint along the bezier path
const positions = useMemo(() => {
if (!routePath || !viewport.width || !viewport.height) return [];
const pathEl = document.createElementNS('http://www.w3.org/2000/svg', 'path');
pathEl.setAttribute('d', routePath);
const totalLength = pathEl.getTotalLength();
return timeline.map((entry) => {
const point = pathEl.getPointAtLength(entry.routeProgressPercentage * totalLength);
return {
id: entry.id,
x: point.x,
y: point.y,
};
});
}, [routePath, viewport.width, viewport.height]);
Each <WaypointCard /> is rendered inside a pointer-events-none fixed inset-0 wrapper, positioned using style={{ top: '${y}px', left: '${x}px' }} with pointer-events-auto on individual cards for full interactivity!
📱 5. Mobile Responsiveness & Polish
Canvas animations can behave differently on touchscreens. To ensure a seamless user experience across all devices:
-
High DPI Displays (
devicePixelRatio): The canvas width and height scale withwindow.devicePixelRatioto maintain razor-sharp graphics on Retina displays. -
Mobile Viewport Optimization: On mobile devices (
< 768px), the site automatically adjusts layout to a compact timeline feed (<MobileTimelineView>), preventing horizontal clipping while preserving background canvas rendering. - Dev.to API Integration: Live technical posts and engineering logs are dynamically fetched from the Dev.to REST API and formatted within the site's dark tactical aesthetic.
💡 Key Takeaways
- Separate Heavy Computation from Render Frames: Offscreen canvas buffering is essential when combining complex mathematical noise/vector generation with interactive animations.
- Combine Vector Math with HTML DOM: HTML5 Canvas is great for background graphics and glows, while HTML overlay cards provide accessibility, crisp typography, and easy event handling.
- Layered Canvas Effects: Combining multiple strokes with varying blur radii creates impressive neon lighting effects without WebGL overhead.
🌐 Links & Source Code
- Tech Stack: React 19, TypeScript, Vite, Tailwind CSS v4, HTML5 Canvas 2D API, GSAP ScrollTrigger, Simplex-Noise, D3-Contour, Lucide React.
- Author: Vitor (@vitorstick)
What are your thoughts on combining Canvas 2D with GSAP and HTML overlays? Feel free to drop questions or feedback in the comments below!
Top comments (0)