All posts

April 15, 2026

Building a Portfolio with Next.js 16

Design decisions, animation patterns, and lessons from building this portfolio with Next.js 16 App Router, HeroUI, Tailwind CSS 4, and Framer Motion 12.

Building a Portfolio with Next.js 16

Starting Point

Most developer portfolios look the same — a name, a list of skills, a grid of projects, a contact form. I wanted something that felt alive without being noisy. The constraint I set for myself: every visual detail had to serve a purpose.

The stack I landed on: Next.js 16 App Router, HeroUI, Tailwind CSS 4, Framer Motion 12, and next-themes for dark/light mode.

App Router and Server Components

Next.js App Router changes how you think about data. The portfolio has no CMS or API — all content lives in lib/data.ts as typed constants. Server components read that data at build time and render zero client-side JavaScript for the static sections. Only the animated parts opt into "use client".

The result: the page ships with a near-zero JavaScript bundle for content, and Framer Motion code only loads where it's actually needed.

Scroll-Driven Animations

Framer Motion 12's useScroll and useTransform make scroll-linked effects declarative. The contact card uses a "jelly pop" pattern — scaling from 85% to full size as it enters the viewport, with a spring wrapper for the elastic feel:

const { scrollYProgress } = useScroll({ target: ref, offset: ["start end", "end end"] });
const scale = useTransform(scrollYProgress, [0, 0.6], [0.85, 1]);
const y = useTransform(scrollYProgress, [0, 0.6], [60, 0]);
const scaleSpring = useSpring(scale, { stiffness: 120, damping: 20 });
const ySpring = useSpring(y, { stiffness: 120, damping: 20 });

The useSpring wrapper is the key — without it, scroll animations feel mechanical. With it, they feel physical.

For section reveals, a ScrollReveal wrapper uses useInView with a -100px margin so elements start animating just before they're fully visible:

const ref = useRef(null);
const isInView = useInView(ref, { once: true, margin: "-100px" });

Tailwind CSS 4 and CSS-First Theming

Tailwind v4 replaced the tailwind.config.js token system with @theme inline blocks in CSS. Theme tokens are now CSS variables, which means dark mode is just a class swap — no JavaScript involved:

:root {
  --primary: #3b82f6;
  --card: #ffffff;
}

.dark {
  --primary: #60a5fa;
  --card: #18181b;
}

@theme inline {
  --color-primary: var(--primary);
  --color-card: var(--card);
}

Every Tailwind utility that references primary or card now responds to the .dark class automatically. next-themes handles toggling the class on <html>.

HeroUI for Structural Components

HeroUI (formerly NextUI) handles buttons, dividers, and layout primitives. It pairs naturally with Tailwind and respects the CSS variable theme, so customization is minimal — you're mostly composing, not overriding.

What I'd Do Differently

Mobile-first from the start. I built desktop layouts first and retrofitted responsive styles. The contact card in particular needed three rounds of mobile adjustments. Starting from the smallest viewport and expanding outward is strictly easier.

Fewer animation variants. I defined separate FadeIn, ScrollReveal, and StaggerContainer components but they share 80% of the same logic. A single configurable Motion primitive would have been cleaner.

What Came Next

The blog you're reading was added after the initial launch — markdown files parsed with gray-matter, rendered with react-markdown. Adding a new section to a well-structured Next.js project is genuinely fast when data, types, and layout concerns are cleanly separated from the start.