← 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.

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.