May 10, 2026

How I built eliel.work

A portfolio for a senior full-stack engineer is a strange object. The person reading it can read code; “pretty animations” alone don’t impress them. But the page also has to land on mobile in under two seconds, rank organically, and convince a recruiter who knows nothing about your stack that you take craft seriously. So the constraints are loud:

  • Lighthouse 100/100/100/100, mobile throttled, on day one.
  • Animation that reads as Apple-like without becoming a playground.
  • Editorial typography that earns the page’s voice — not a stack of utilities pretending to have personality.
  • Multilingual (EN default, PT-BR mirror) without doubling the maintenance cost.

Here’s how the site you’re reading came out.

The stack

I’m a daily Next.js user. I didn’t pick Next.js for this.

A portfolio is ~80% parked content and ~20% animation. Next.js ships React runtime per page even when the page is structurally static, which costs ~50–80kb of JS that hydrates components that aren’t moving. On a throttled mobile profile, that’s the difference between a 100 and a 92 in Lighthouse Performance. Astro emits HTML at build time and only hydrates declared islands.

So: Astro 5 with output: 'static', React islands for GSAP, MDX content collections for the case studies and this blog, Tailwind v4 with a CSS-first @theme block driving every token from DESIGN.md. Deployment to Cloudflare Pages — static, no edge runtime needed in v1.

The trade is real — Astro doesn’t give me the Next.js mental model I’ve spent years sharpening. But I’d rather take that cost once than pay the perf tax on every page load.

Fraunces and Geist instead of Domaine and ABC Favorit

The design system is consciously inspired by Resend: pure black canvas, oversized editorial serif headlines, atmospheric glows in low-opacity accent colors, no drop shadows. Resend uses Domaine Display and ABC Favorit — both proprietary, both expensive.

For a personal portfolio, paying USD ~200–600 per font style is overkill. The substitution stack:

  • Fraunces (variable, optical sizing) replaces Domaine Display at 76–96px headline sizes. With ss01 and liga features turned on, it’s the closest free serif in voice.
  • Geist replaces ABC Favorit for marketing body copy. Less personality at 16px than ABC Favorit, but compensated with a tighter -0.5% letter-spacing.
  • Inter for UI labels. Geist Mono for code.

All four are self-hosted via fontsource. The variable Fraunces is preloaded for the above-the-fold hero; the rest load with font-display: swap.

One gotcha I burned an hour on: fontsource declares the family as 'Geist Sans', not 'Geist'. Get the name wrong and the browser falls through to system-ui without complaining.

GSAP inside React islands

The animation map is four cirurgical moments, not a parade:

  1. Hero headline — GSAP SplitText breaks the <h1> into words and reveals them with a 0.04s stagger, blur 8→0, y 20→0, opacity 0→1.
  2. Work previewScrollTrigger pins the section for one viewport-height while three case study cards reveal sequentially.
  3. FortCred numbers band — counters animate 0 → final values with power2.out over 1.6s. Numbers above 1000 read with locale-aware thousand separators.
  4. Page transitions — Astro’s native <ClientRouter /> driving the browser’s View Transitions API. Case study card titles carry view-transition-name: case-<slug> so the title morphs from the work index into the dedicated page.

Each animation lives in a React component (.tsx) that consumes GSAP via the @gsap/react useGSAP hook. The hook runs the animation inside a gsap.context() and cleans it up on unmount automatically — important because Astro’s View Transitions tear down components on client-side navigation.

I import these islands into the surrounding .astro page with client:visible, so the bundle for the FortCred numbers band isn’t fetched until the user scrolls it into view. The hero, which is above the fold, uses client:load instead — there’s no point waiting for an IntersectionObserver round-trip when the section is the first thing on screen.

A non-obvious detail: prefers-reduced-motion: reduce short-circuits every island. The static markup is already at the final styled state; reduced-motion users see content instantly with no GSAP timelines running and Lenis disabled.

The hover arrow that everyone copies

The buttons on this site do the move you’ve seen on landing pages all year: arrow hidden, hover slides it in from the left, text shifts slightly. The implementation people most often reach for is width animation with overflow-hidden, which is jank on Safari. The cleaner version uses CSS grid template columns:

<button class="group inline-flex items-center">
  <span class="transition-transform group-hover:-translate-x-0.5">
    {label}
  </span>
  <span class="grid grid-cols-[0fr] group-hover:grid-cols-[1fr]
               transition-[grid-template-columns] duration-200">
    <span class="overflow-hidden">
      <ArrowRight class="ml-1.5 -translate-x-1.5 opacity-0
                         group-hover:translate-x-0 group-hover:opacity-100
                         transition-all duration-200" />
    </span>
  </span>
</button>

The arrow’s column animates from 0fr to 1fr — natural content width — without any width hardcoding. The icon inside is fade + translate. The text gets a 2px shift left for visual emphasis. Two-hundred milliseconds, ease-out, no jank.

i18n without doubling the code

Astro 5’s i18n routing puts EN at / (default) and PT-BR at /pt/. Content collections are organized by locale folder (src/content/work/en/, src/content/work/pt/), and the lookup helpers in src/lib/content.ts filter by prefix. Hreflang tags including x-default are emitted on every page from a <SeoHead> component; the sitemap pairs every URL with its counterpart via <xhtml:link rel="alternate">.

I treat the EN side as canonical for international search. The PT-BR mirror is for the part of my work that lands in Brazil — clients, conference posts, anyone who reads in Portuguese first. Adding a new case study is a single MDX file in each locale folder, no code touched.

What I’d do differently

If I were starting again I’d probably reach for the same stack. The places I’d tighten:

  • OG image caching: currently regenerated on every build via satori. A content hash gate would cut build time in CI.
  • Lenis on slower devices: I’d pull the inertia coefficient down a notch for low-end Android — the default feels great on a M1 MacBook and slightly heavy on a 2-year-old Moto.
  • Blog scaffolding: I shipped the routes, RSS, and empty state, but I hadn’t actually written a post until this one. The honest answer is: have at least one published before launch.

Anyway. This is one. The full code lives at the repo, and you can see how each decision lands by browsing the case studies — that’s where the engineering thinking shows up against real production constraints.