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.
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.
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.
┌─────────────────────────────────────────────────┐ │ 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 |
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
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>
);
}
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)
: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 {
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);
}
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 |
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.
[[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
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.
Key Takeaways
- Decouple input from rendering: The scroll handler updates a target; the RAF loop renders. This separation prevents frame drops during fast scrolling.
- Image sequences > video for interaction: WebP frames give frame-precise control that video tags cannot match, at 96% smaller file size.
- Progressive loading matters: Showing frames as they decode (not waiting for all 75) creates a smooth experience even on slow connections.
- Your portfolio IS a case study: The medium demonstrates the message — engineering skill shown through the site itself, not just described in text.