Editorial poster: the DVZ wordmark beside a sphere with four route poses joined by a single path.
Product

Devanzire

portfolio and visual system

StatusLive
RoleDesign and development
Year2025
Stack
next.jsreactgsapthree.jstailwind

Overview

The site you are looking at. A hand-built design system on Next.js, with continuous route transitions and a WebGL sphere that persists across pages.

The problem

A portfolio that reloads and cross-fades on every navigation feels like a document, not a product. Between one page and the next there is a white flash, and that flash is the admission that nothing was ever continuous: two documents, served one after the other.

I wanted the opposite. Pages that become one another, and something that survives the change to prove it.

The sphere survives the route

The background sphere lives in the layout, not in the page. That is the whole trick: because the layout is never unmounted on navigation, the sphere is never rebuilt. Each route declares where it wants the sphere to sit — lateral shift, scale, two rotations — and the sphere travels to that pose.

The poses are not arbitrary. articles and projects share exactly the same one, the home's open pose, so entering either leaves the sphere untouched and lets the panel morph read on its own. Both long-form routes push it further out and shrink it, because there its job is to stay clear of the text column.

The home declares no pose at all, and that absence is deliberate: while the home is mounted the reveal hook owns the sphere's body outright, and a second system tweening the same values would fight it.

Figure pending/media/projects/devanzire-portfolio/01-poses.webp

The panel hands over its box

The home has a panel that opens. Navigating from it, that panel measures where it is on screen and hands the rectangle to the next page, whose frame grows out of it rather than appearing. It is a FLIP, but the interesting part is where the measurement lives.

Not in React context: the capture happens inside the click handler and is read after a route change, so the producer and the consumer are never mounted at the same time. There is no shared subtree to put it in. It lives at module scope, which is exactly the scope of the problem.

And it expires. A capture is good for the navigation it was made for; without a lifetime, a click that never navigated would leave a rectangle behind and the next unrelated page load would grow out of a box nobody ever saw.

Figure pending/media/projects/devanzire-portfolio/02-morph.webp

One writer per property

This was the hard part, and it was not a bug so much as a category of bug.

The first version had React rendering geometry from ternaries while GSAP wrote those same properties. Both were correct in isolation. Together, the result depended on which ran last — which is another way of saying there was no result, there was a race.

The rule that came out of it is that React decides what state a thing is in, via data-state, and CSS or GSAP decide how it moves. Never both writing left, top or width. Everything else follows from it:

  • The initial state goes in CSS, not gsap.set, so there is no flash before hydration.
  • Only transform and opacity on anything that animates per frame.
  • Interactivity is never gated on an animation finishing. Listeners mount immediately; the animation is only the visual part.
  • Nothing invisible stays operable. Opacity 0 leaves an element clickable and reachable by keyboard, so it travels with inert.

What got written down

The motion layer has a documented behaviour matrix — what the loader, the page entrance and the home deck each do in every scenario — and it is verified in development and production separately.

The reason is Strict Mode: it only runs in next dev, and it mounts, cleans up and remounts every effect on the first render. Animation effects are not idempotent, so the two environments genuinely differ. A failure that only shows up in development is still a real failure. Strict Mode does not cause it, it reveals it.

Live result

Devanzire

More projects



SESSION_ELAPSED00:00:00
LOCALE: ENENV: PROD