← Blog

Building this portfolio: GSAP, Lenis, and no AI slop

This site is React, Vite, TypeScript, Tailwind, GSAP, and Lenis — no CMS, no MDX pipeline, one typed content file (src/content/data.ts) that every section reads from. A few decisions in it were worth writing down, because they came from getting something wrong first.

The nav's glass effect isn't the same trick twice

The project cards and skill tiles use a "glass panel" look: a diagonal gradient sheen plus an inset top-edge highlight, no blur involved. That's deliberate, not a shortcut — backdrop-filter blurs whatever sits behind an element, and these cards sit on a flat background with nothing behind them to blur. The property would compile and do nothing.

The nav is the opposite case. It's sticky, with real page content scrolling underneath it, so backdrop-filter has something to work with there. It runs an SVG feTurbulence + feDisplacementMap filter through it for an actual liquid distortion, not just a blur — gated behind an @supports check, because chaining blur() url(#id) in one backdrop-filter value is Chromium-only, and an unsupported value drops the whole declaration rather than degrading gracefully.

The Projects section is a cylinder, not a slider

Six project cards sit on a 3D cylinder and rotate into view as you scroll, ported from codrops' 3DCarousel demo. Their original uses GSAP's ScrollSmoother to drive it; this site already runs Lenis for smooth scroll, so the carousel scrubs off the same Lenis + ScrollTrigger pairing instead of adding a second smooth-scroll engine for one section. Under prefers-reduced-motion, the rotation never runs at all — a CSS breakpoint alone would still leave those users scrolling past a single frozen card, so the carousel is enabled or disabled in JavaScript, and it changes the section's scroll length either way.

Wiring Lenis to GSAP ScrollTrigger in React

Lenis and ScrollTrigger have to run off one clock. The site creates Lenis inside a useLenis hook with autoRaf: false, drives it from GSAP's ticker, and tells ScrollTrigger to update on every Lenis scroll event. Trimmed to the wiring:

const lenis = new Lenis({ duration: 1.2, autoRaf: false });
lenis.on('scroll', ScrollTrigger.update);

const onTick = (time: number) => lenis.raf(time * 1000);
gsap.ticker.add(onTick);
gsap.ticker.lagSmoothing(0);

That much is the setup the Lenis README documents. The cleanup in the same useEffect removes the ticker callback and destroys the instance, so a remount doesn't leave two loops running. Two bugs only showed up after it.

Nav links past the Projects section scrolled nowhere. Lenis caches the page height and re-measures only through its own ResizeObserver, so it never noticed when ScrollTrigger inserted a pin-spacer and made the page taller. lenis.scrollTo() then clamped every target to the old, shorter height. The fix is one listener:

const onRefresh = () => lenis.resize();
ScrollTrigger.addEventListener('refresh', onRefresh);

Opening the page with a #hash in the URL had the same cause. The pinned section can finish sizing after the effect runs, so the hook waits one animation frame, calls ScrollTrigger.refresh(), and only then jumps with lenis.scrollTo(target, { immediate: true }). And nothing on the page scrolls with a native window.scrollTo while Lenis is running: it falls out of sync with Lenis's own position and freezes ScrollTrigger scrubs mid-tween, which looks like a layout bug and isn't one.

What "no AI slop" means as a rule, not a vibe

The project's own rules ban comments that restate what code does, defensive checks for states that can't happen, and premature abstractions — three similar lines beat a one-use helper. Copy gets the same scrutiny: no eyebrow lines, no repeating the same fact across two sections, no raw exposed email addresses. This post, and the two before it, went through that same pass before publishing — including a rewrite to cut em dashes down to one or two and drop a line that read as a "here's the honest part" reveal instead of just stating the fact.

What's actually running under this post

The blog itself: React Router routes, per-page metadata through react-helmet-async, Article JSON-LD, and a Puppeteer prerender step that snapshots each route to static HTML at build time so a crawler that doesn't execute JavaScript still gets a real title and description instead of an empty <div id="root">. Source for all of it, and the rest of what I've shipped, is on the projects page.