DEV Community

Cover image for How I Built a Timeline Portfolio using Canvas 2D
Vitor Ferreira
Vitor Ferreira

Posted on

How I Built a Timeline Portfolio using Canvas 2D

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)   │
 └─────────────────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode
  1. Procedural Terrain Layer (Offscreen Canvas Snapshot): Renders multi-octave 2D elevation noise converted to elevation iso-contour vectors via d3-contour.
  2. 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.
  3. 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:

  1. We sample height values across a 2D grid using Simplex Noise.
  2. We combine multiple noise frequencies (octaves) so terrain has both large mountain peaks and subtle ridge details.
  3. We feed the 2D heightmap matrix into d3-contour to 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;
};
Enter fullscreen mode Exit fullscreen mode

⚡ 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 a useRef.
  • 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);
};
Enter fullscreen mode Exit fullscreen mode

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();
};
Enter fullscreen mode Exit fullscreen mode

🎯 4. Syncing Scroll & Positioning HTML Waypoint Overlays

To map viewport scrolling to the route trail, we use GSAP ScrollTrigger:

  1. A wrapper <main> container is given a large virtual scroll height (e.g., min-h-[400vh]).
  2. GSAP calculates a normalized scrollProgress between 0.0 (top of page) and 1.0 (bottom of page).
  3. We convert each milestone's routeProgressPercentage into 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]);
Enter fullscreen mode Exit fullscreen mode

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 with window.devicePixelRatio to 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

  1. Separate Heavy Computation from Render Frames: Offscreen canvas buffering is essential when combining complex mathematical noise/vector generation with interactive animations.
  2. 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.
  3. 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)