Building a portfolio with Next.js 16
How I rebuilt julientavernier.fr from scratch on Next.js 16, Tailwind v4 and Motion: the performance decisions, the animation architecture and a debugging war story.
I rebuilt julientavernier.fr from scratch. The old site had aged out, and I wanted the replacement to hold up against the kind of work that lands on Awwwards: dark, deliberate, fast. This is a walk through the decisions that shaped it, including the ones I would defend and the one bug that cost me an afternoon.
The stack
The site runs on Next.js 16 with the App Router and React Server Components, on top of React 19 and TypeScript 5. Most of the page is static content rendered on the server, so the client only hydrates the parts that actually move.
Styling is Tailwind CSS v4 in config-less mode. There is no tailwind.config file at all. The entire theme lives in globals.css under an @theme inline block: OKLCH color tokens, the radius scale, and keyframes, all in one place. Working in OKLCH instead of hex made the brand gradient easy to reason about. The accent runs from a violet at oklch(0.68 0.19 293) to an indigo at oklch(0.62 0.21 275), and that single gradient is the visual signature across buttons, the logo mark and headline highlights.
For animation I use Motion v12, the successor to Framer Motion. I pulled base primitives from the shadcn aceternity and magicui registries, then reworked most of them heavily. Registries are a good starting point, not a finish line.
Paying for motion without paying for it
A dark site leans on ambient motion to feel alive, and that is exactly where the frame budget goes to die. A few rules kept it cheap.
The hero originally had an animated dot grid built from roughly 5000 SVG nodes. It looked right and scrolled like a slideshow. I replaced the whole thing with a pure CSS radial-gradient texture that costs zero DOM nodes:
.hero-dots {
background-image: radial-gradient(
circle at center,
var(--dot) 1px,
transparent 1px
);
background-size: 24px 24px;
}The ambient glows in the hero are real animations, so I gate them. An IntersectionObserver pauses them the moment the section leaves the viewport, which means nothing is repainting while you read the rest of the page. Every animation on the site touches only transform, opacity or filter, the three properties the compositor can handle without a layout or paint pass. And a global prefers-reduced-motion guard neutralizes both the Motion variants and the CSS animations for anyone who asks their operating system to calm things down.
One way to reveal things
Scroll entrances are the easiest thing to get subtly wrong across a large page, so I gave the whole site exactly one primitive for them. Reveal wraps a Motion element, fades and lifts it into place once, and shares a single easing curve, an expo-out at [0.16, 1, 0.3, 1].
<Reveal delay={index * 0.06} blur>
<h2>Featured projects.</h2>
</Reveal>The important detail is that each element observes its own viewport entry with whileInView and a once flag, rather than a parent orchestrating its children through variant propagation. I tried the propagation route first. In Motion v12 it was unreliable enough that elements would occasionally settle in the wrong state, so per-element won. Card grids add an index-based stagger on top, which is the only place a delay is doing any real work.
The bug that was not a bug
At one point every entrance animation looked broken in my automated screenshots. Elements sat at opacity 0, stuck on their initial state, as if Motion had never fired. The code was correct, which is the worst kind of correct.
The cause was the capture setup. A backgrounded browser tab reports document.hidden as true, and the browser freezes requestAnimationFrame in hidden tabs to save work. Motion drives its timeline off requestAnimationFrame, so with the tab in the background the animation never advanced past frame zero. Nothing was wrong with the site. The fix was to stop treating it as a rendering bug and instead force the final states with injected CSS whenever I needed to review a static frame. An afternoon of chasing a ghost, then a one-line workaround.
SEO with no loose assets
Everything search engines and social cards read is generated in code. Metadata goes through the Next.js Metadata API with a metadataBase and a canonical URL, plus a Person JSON-LD block so the site describes who it is about in structured terms.
The images are the part I am happiest with. The Open Graph image, the icon and the apple touch icon are all rendered with next/og at request time. The only binary left in the repo is a legacy favicon.ico, itself derived from the generated icon, so there is nothing to re-export when the brand shifts. sitemap.ts and robots.ts round it out through the App Router file conventions.
What it measures
On production the numbers landed where I wanted. Time to first byte is around 40 ms. Cumulative layout shift is a flat 0.00, which is what you get when nothing loads late and pushes content around. Largest contentful paint is 1.35 s, and most of that is the hero entrance animation I chose to keep, a deliberate tradeoff rather than a regression. Lighthouse reports 100 in SEO, accessibility and best practices.
Shipping it
Deployment is boring on purpose. The repo is connected to Vercel, and a push to main builds and deploys straight to production. No manual step, no separate pipeline to babysit.
Takeaways
A few things I would carry into the next build:
- Prefer a CSS texture over a large DOM tree for anything decorative. The dot grid swap was the single biggest win.
- Animate only transform, opacity and filter, and pause offscreen work. The compositor stays happy and the battery lasts.
- Give a large site one entrance primitive. Consistency beats cleverness, and it removes a whole class of one-off bugs.
- Generate SEO images in code. Fewer assets means fewer things that rot.
- When something looks broken but the code is right, question the environment before the code.
None of this is exotic. It is a small pile of ordinary decisions that add up to a site that feels quick and stays out of its own way.