I spent years telling clients their portfolio needed to be "premium."
Mine didn't even exist.
C'est le cordonnier le plus mal chaussé, as we say in France. Time to fix that.
Here's how I built emericguyon.com — and why I'm giving you the entire source code.
The stack
No overthinking. Tools I already know and trust:
- Nuxt 3 — SSR, auto-imports, file-based routing. The Vue ecosystem at its best.
-
Vue 3 — Composition API,
<script setup>, TypeScript everywhere. - Tailwind CSS 4 — utility-first, fast iteration, no CSS file archaeology.
- GSAP — because CSS animations hit a wall real fast when you want scroll-triggered timelines and canvas work.
That's it. No CMS, no headless backend, no GraphQL layer. It's a portfolio, not a SaaS.
The canvas hero — a.k.a. "the thing everyone asks about"
The homepage background is a <canvas> that renders actual code scrolling in real-time. Not a video. Not a GIF. Actual characters drawn frame by frame with fillText.
Here's the gist:
// Each frame, we only draw visible lines (modulo scroll)
const totalLines = cachedLines.length
const totalContentH = totalLines * lineHeight
const scrollY = scrollOffset.value % totalContentH
const visibleLines = Math.ceil(cssH / lineHeight) + 2
const startLine = Math.floor(scrollY / lineHeight)
for (let v = 0; v < visibleLines; v++) {
const i = (startLine + v) % totalLines
const y = v * lineHeight - (scrollY % lineHeight)
// draw line i at position y
}
The trick: instead of drawing ALL lines and skipping the invisible ones (like I did initially with a naive [...lines, ...lines, ...lines] approach), we use modular arithmetic to create infinite seamless scrolling while only iterating over visible lines.
Performance matters. On mobile, a canvas running at 60fps will murder your battery. So:
- FPS cap at 24fps on mobile — your eye doesn't notice the difference on scrolling text
- 25% of the code text on mobile vs 70% on desktop — fewer tokens to parse and draw
-
prefers-reduced-motionsupport — if the user's OS says "calm down", we render one static frame and stop - IntersectionObserver to pause everything when the hero is scrolled off-screen
// Respect the user's preferences
const prefersReduced = window.matchMedia(
'(prefers-reduced-motion: reduce)'
).matches
if (prefersReduced) {
// Draw once, then stop. No RAF loop.
drawCode(dimCanvasRef.value!, true)
return
}
The result: a MacBook Pro stays cool. A 2018 laptop doesn't catch fire. Mission accomplie.
Scroll animations — the composable approach
Every section has reveal animations: words sliding up, letters appearing one by one, cards fading in with stagger. Doing this imperatively in every component would be a mess.
So I wrapped everything in a single composable:
export function useScrollAnimation() {
const triggers: ScrollTrigger[] = []
onMounted(() => {
gsap.registerPlugin(ScrollTrigger)
})
// Cleanup — kill ALL triggers when the component unmounts
onUnmounted(() => {
triggers.forEach(t => t.kill())
})
function wordReveal(selector: string, delay = 0) {
onMounted(() => {
// Split text into words, wrap each in a span
// Animate from y: 100% with stagger
const tween = gsap.from(`${selector} .word-reveal`, {
y: '100%',
duration: 0.7,
stagger: 0.05,
ease: 'power3.out',
delay,
scrollTrigger: {
trigger: selector,
start: 'top 85%',
toggleActions: 'play none none none',
lazy: true,
},
})
if (tween.scrollTrigger) triggers.push(tween.scrollTrigger)
})
}
return { wordReveal, letterReveal, staggerReveal, /* ... */ }
}
Using it in a component is one line:
<script setup>
const { wordReveal, letterReveal } = useScrollAnimation()
letterReveal('.section-label')
wordReveal('.section-title', 0.2)
</script>
The key insight: every ScrollTrigger is tracked and killed on unmount. GSAP doesn't clean up after itself in Vue — if you navigate away and back, you'll get duplicate animations stacking. The triggers array + onUnmounted prevents that.
Dark/light theme — no flash, no FOUC
The theme toggle is simple in theory. In practice, every portfolio with dark mode gets one thing wrong: the flash of wrong theme on reload.
The fix is a blocking <script> in the <head> that runs before any rendering:
// In nuxt.config.ts head
{
innerHTML: `(function(){
var m = document.cookie.match(/(?:^|;\\s*)theme=(dark|light)/);
if (m && m[1]==='dark') document.documentElement.classList.add('dark');
else if (!m && window.matchMedia('(prefers-color-scheme:dark)').matches)
document.documentElement.classList.add('dark');
})()`
}
This runs synchronously before the first paint. No flash. The composable then hydrates the reactive state from the same cookie:
export function useTheme() {
function init() {
// Check cookie first
const match = document.cookie.match(/(?:^|;\s*)theme=(dark|light)/)
if (match) {
isDark.value = match[1] === 'dark'
return
}
// Fall back to system preference
const prefersDark = useMediaQuery('(prefers-color-scheme: dark)')
isDark.value = prefersDark.value
}
return { isDark: readonly(isDark), toggle, init }
}
Cookie > system preference > default. Simple hierarchy, zero flash.
Bilingual without the headache
French is the default. English lives under /en/. Nuxt i18n handles the routing, and all content lives in two JSON files.
The trick that saved me hours: the seo.title key is translated, and useSeo() picks the right one automatically. No conditional logic, no if (locale === 'en') anywhere.
// Works in both languages, zero conditional code
useSeo({
title: t('seo.title'),
description: t('seo.description'),
})
OG images are also per-language (og-image-fr.jpg, og-image-en.jpg). Google sees two distinct pages with proper hreflang tags.
Performance — because Lighthouse doesn't lie
The initial Lighthouse score was... humbling. Here's what moved the needle:
| What I did | Impact |
|---|---|
WebP images with responsive srcset (640w, 1200w) |
-80% image weight |
| Self-hosted fonts (Clash Display, Satoshi) instead of Fontshare CDN | Eliminated render-blocking external requests |
loading="lazy" on all below-the-fold images |
Reduced initial payload |
fetchpriority="high" on the hero image |
LCP dropped significantly |
Lazy-loaded sections with Nuxt's Lazy prefix |
Less JS to parse upfront |
| FPS-capped canvas + reduced-motion support | TBT dropped on mobile |
The source code is yours
The entire repo is public: github.com/emricooo/portfolio-emeric-guyon
Fork it. Break it apart. Steal the canvas effect. Use the scroll animation composable. Swap my face for yours (please do).
The project structure is clean and documented:
app/
components/
home/ # Hero, About, Services, Skills, Projects, Clients
project/ # Project detail page
icons/ # SVG icon components
ui/ # Badge, ThemeToggle
composables/ # useScrollAnimation, useTheme, useSeo
pages/ # index, projets/[slug]
data/ # Projects, skills, clients
MIT licensed. No strings attached.
Honest disclaimer
Is it perfect? Nope. The canvas could probably be a WebGL shader. Some components could be split further. There are always things you'd do differently with hindsight.
But at some point you have to ship. This is where I landed, and I'm happy with it. If you spot something that could be better — tell me, that's literally why I open-sourced it.
If you build something with it, tag me — I'd love to see what you make.
Et si vous êtes français, on se dit "salut" sur LinkedIn.
What's the one thing you always struggle with when building your portfolio? Drop it in the comments.
Top comments (0)