DEV Community

Cover image for I open-sourced my Nuxt 3 portfolio. Here's what's inside
Emeric Guyon
Emeric Guyon

Posted on

I open-sourced my Nuxt 3 portfolio. Here's what's inside

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

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-motion support — 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
}
Enter fullscreen mode Exit fullscreen mode

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, /* ... */ }
}
Enter fullscreen mode Exit fullscreen mode

Using it in a component is one line:

<script setup>
const { wordReveal, letterReveal } = useScrollAnimation()
letterReveal('.section-label')
wordReveal('.section-title', 0.2)
</script>
Enter fullscreen mode Exit fullscreen mode

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

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

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'),
})
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)