Creative Development / React

Scrollytelling Portfolio

A personal portfolio with HTML5 Canvas frame scrubbing, parallax text overlays, glassmorphism UI, and a complete dark/light theme system — built in React + Vite.

Role Designer & Developer
Stack React, Vite, Canvas
Type Personal Portfolio
Deployment Netlify (SPA)
1

Design Challenge

Most developer portfolios are either visually impressive but technically shallow, or technically impressive but visually generic. The goal was to create a portfolio that demonstrates both design sensibility AND engineering depth — a site where the medium IS the message.

The central mechanic: a scroll-linked animation that scrubs through a cinematic image sequence as the user scrolls, creating a "scrollytelling" experience similar to award-winning sites on Awwwards. This approach replaces a traditional video tag with something interactive, performant, and unique.

Core Challenge: Build a scroll-linked canvas animation that renders at 60fps, combined with parallax text overlays, glassmorphism UI, dark/light themes, and responsive design — all while keeping the bundle small and load times fast on Netlify.
60fps
Scroll Animation
75
WebP Frames
<3s
Initial Load (3G)
2
Theme Modes
2

Technical Architecture

The site is built with React 19 + Vite 8, using vanilla CSS custom properties for the design system and Framer Motion for supplementary animations. The core scrollytelling mechanic uses a custom Canvas rendering engine — no video tags, no heavy animation libraries.

Architecture Overview
┌─────────────────────────────────────────────────┐
│                  React App (Vite)                │
├─────────────────────────────────────────────────┤
│  ScrollyCanvas.jsx     ← Canvas frame renderer  │
│  ScrollyOverlay.jsx    ← Parallax text layers   │
│  Navbar.jsx            ← Fixed glass nav        │
│  ProjectsSection.jsx   ← Glassmorphic cards     │
│  AboutSection.jsx      ← Bio + education        │
│  ExperienceSection.jsx ← Work history           │
│  SkillsSection.jsx     ← Filterable skills      │
│  ContactSection.jsx    ← WhatsApp integration   │
│  ThemeContext.jsx       ← Dark/Light state       │
├─────────────────────────────────────────────────┤
│  index.css             ← Design system (473ln)  │
│  sequence/frame_*.webp ← 75 animation frames    │
└─────────────────────────────────────────────────┘

Key Technical Decisions

Decision Choice Rationale
Canvas over <video> HTML5 Canvas + RAF Frame-precise control, no codec issues, works on all browsers
WebP image sequence 75 frames @ 15fps Smaller than video, progressive loading, no buffering
Lerp-based interpolation requestAnimationFrame Smooth 60fps frame transitions even on slower scroll
CSS Custom Properties Vanilla CSS (no Tailwind) Zero build overhead, full theme control, 473-line design system
React Context ThemeContext Lightweight dark/light toggle with localStorage persistence
Netlify SPA netlify.toml config Immutable asset caching + SPA redirect rules
3

Canvas Frame Scrubbing Engine

The heart of the portfolio is the scroll-linked canvas animation. As the user scrolls through a 500vh container, the scroll progress (0 to 1) maps to frame indices (0 to 74). The engine uses linear interpolation (lerp) to smoothly transition between frames, preventing jarring jumps.

Canvas Rendering Pipeline

Scroll-to-Frame Rendering Pipeline

Scroll passive listener Progress 0-1 target = p * 74 Lerp + RAF smooth interpolation Canvas Render drawImage(frame[idx]) 60fps | 75 WebP frames ...75 frames
ScrollyCanvas.jsx — Core Logic
import { useRef, useEffect, useState } from 'react';

export default function ScrollyCanvas() {
  const canvasRef = useRef(null);
  const framesRef = useRef([]);
  const [loaded, setLoaded] = useState(0);
  const targetFrame = useRef(0);
  const currentFrame = useRef(0);

  // Preload all 75 WebP frames
  useEffect(() => {
    const TOTAL = 75;
    let count = 0;
    for (let i = 1; i <= TOTAL; i++) {
      const img = new Image();
      img.src = `/sequence/frame_${String(i).padStart(3,'0')}.webp`;
      img.onload = () => {
        count++;
        setLoaded(count);
        framesRef.current[i - 1] = img;
      };
    }
  }, []);

  // RAF loop with lerp interpolation
  useEffect(() => {
    const canvas = canvasRef.current;
    const ctx = canvas.getContext('2d');
    let raf;

    const render = () => {
      // Lerp toward target for smooth transitions
      currentFrame.current += (targetFrame.current - currentFrame.current) * 0.15;
      const idx = Math.round(currentFrame.current);

      if (framesRef.current[idx]) {
        canvas.width = canvas.offsetWidth;
        canvas.height = canvas.offsetHeight;
        ctx.drawImage(framesRef.current[idx], 0, 0,
          canvas.width, canvas.height);
      }
      raf = requestAnimationFrame(render);
    };
    render();
    return () => cancelAnimationFrame(raf);
  }, []);

  // Scroll listener maps progress to frame index
  useEffect(() => {
    const onScroll = () => {
      const el = document.querySelector('.scroll-container');
      const rect = el.getBoundingClientRect();
      const progress = Math.max(0, Math.min(1,
        -rect.top / (rect.height - window.innerHeight)));
      targetFrame.current = progress * 74;
    };
    window.addEventListener('scroll', onScroll, { passive: true });
    return () => window.removeEventListener('scroll', onScroll);
  }, []);

  return (
    <div className="canvas-container">
      <canvas ref={canvasRef} className="canvas-element" />
      <div className="canvas-vignette" />
      {loaded < 75 && (
        <div className="loading">
          Loading... {Math.round(loaded/75*100)}%
        </div>
      )}
    </div>
  );
}
Performance: The RAF loop runs at 60fps even on mid-range devices. WebP frames are ~8KB each (total ~600KB), compared to a 15MB video file. Progressive preloading ensures frames appear as soon as they're decoded — no full-load blocking.
4

Design System: Glassmorphism & Themes

The visual language is built on glassmorphism — frosted glass cards with backdrop-filter blur, semi-transparent backgrounds, and subtle borders. The dark mode is the default (matching the cinematic canvas), with a light mode that maintains the same token structure with overridden values.

CSS Custom Properties (Tokens)

index.css — Theme Tokens
:root {
  --font-primary: 'Plus Jakarta Sans', system-ui, sans-serif;
  --font-display: 'Space Grotesk', system-ui, sans-serif;

  /* Dark Mode (Default) */
  --bg-dark: #07090e;
  --bg-surface: rgba(13, 17, 26, 0.75);
  --border-subtle: rgba(255, 255, 255, 0.08);
  --border-glow: rgba(139, 92, 246, 0.35);

  --color-primary: #8b5cf6;
  --color-secondary: #06b6d4;
  --color-accent: #f59e0b;

  --text-main: #f8fafc;
  --text-muted: #94a3b8;
}

[data-theme="light"] {
  --bg-dark: #f8fafc;
  --bg-surface: rgba(255, 255, 255, 0.9);
  --color-primary: #7c3aed;
  --text-main: #0f172a;
  --text-muted: #475569;
}

Glassmorphism Component

Glass Card Pattern
.glass-card {
  background: var(--bg-surface);
  backdrop-filter: blur(20px);
  -webkit-backdrop-filter: blur(20px);
  border: 1px solid var(--border-subtle);
  border-radius: 1.25rem;
  box-shadow: 0 10px 30px -5px rgba(0,0,0,0.5);
  transition: all 0.35s cubic-bezier(0.16, 1, 0.3, 1);
}

.glass-card:hover {
  border-color: var(--border-glow);
  transform: translateY(-4px);
  box-shadow: var(--glow-violet);
}
5

Accessibility & Performance

Despite the visual complexity, the portfolio maintains strong accessibility foundations. The canvas animation is decorative (aria-hidden), keyboard navigation is fully supported, and the reduced-motion media query disables all animations.

Feature Implementation
Keyboard Navigation Full tab order, visible focus rings on all interactive elements (2px solid primary color)
Screen Readers Canvas marked aria-hidden="true"; semantic HTML sections with proper heading hierarchy
Reduced Motion @media (prefers-reduced-motion: reduce) disables all keyframe animations and transitions
Color Contrast All text meets WCAG AA (4.5:1); headings meet AAA (7:1) in both themes
Skip to Content Hidden skip link appears on keyboard focus for quick section navigation
Performance Lazy-loaded below-fold sections, preloaded critical frames, immutable asset caching on Netlify
6

Deployment & Outcome

Deployed on Netlify with optimized caching headers — immutable assets get long cache lifetimes, HTML gets SPA redirect rules. The build output is a single JS bundle (~85KB gzipped) plus 75 WebP frames totaling ~600KB.

netlify.toml
[[headers]]
  for = "/assets/*"
  [headers.values]
    Cache-Control = "public, max-age=31536000, immutable"

[[headers]]
  for = "/sequence/*"
  [headers.values]
    Cache-Control = "public, max-age=31536000, immutable"

[[redirects]]
  from = "/*"
  to = "/index.html"
  status = 200
60fps
Canvas Animation
~85KB
JS Bundle (gzip)
~600KB
Total Image Assets
2
Themes (Dark + Light)

The portfolio demonstrates the ability to not only design interfaces but engineer them with performance, accessibility, and maintainability in mind. It serves as both a showcase of work and a proof of craft.

React.js Canvas API Scrollytelling Animation Accessibility Dark/Light Themes Netlify Performance

Key Takeaways

← Previous: Meditate UX Back to All Projects