Hello. I’m Ben Paine. I’m a artistic developer and designer based mostly in San Diego, CA. I’m going to show the best way to create distinctive web page transitions utilizing WebGPU. I’ve discovered that web page transitions, when executed proper, exponentially improves the consumer expertise.
The rationale I make the most of WebGPU (this works the identical with WebGL, I’ve been utilizing WebGPU as a contemporary various) is to basically eradicate the notion of the browser ‘popping’ the DOM state. With one steady scene, we are able to trick the consumer expertise to seamlessly transition between pages.
For the sake of the tutorial’s focus, I’ll very briefly be diving into the logic of the WebGPU renderer or the SPA scaffolding, however basically I’m binding the textures to a DOM ingredient and utilizing a fundamental picture texture. All of this may be seen throughout the supply code.
1. Getting Began
Right here I’ll lay out a little bit of the scaffold and a bit of bit about how the SPA works. It’s essential for example this as a result of understanding a shopper router will enable you perceive how the transitions work, however I can be very high-level and temporary.
There are two layers stacked on prime of one another. You probably have labored with WebGL/GPU prior to now that is acquainted. There’s a DOM layer with “slots” for the photographs and a Canvas layer with one single Scene that masses the entire picture planes. The picture planes persist all through the entire web site and are certain to the DOM slots. The picture planes are created as soon as at startup and persist. We’re controlling the visibility based mostly on the web page and out there slots.
Every picture aircraft carries a bounds = { x, y, w, h, z } expressed in CSS pixels utilizing getBoundingClientRect(). At any second the aircraft’s bounds are owned by precisely one in every of:
- DOM Monitoring: The aircraft factors to a DOM “slot” and is up to date each body.
- Guide management:
aircraft.trackedELisnulland the bounds are written instantly. This occurs in the course of the transition when the DOM is damaged down and destroyed and the transition depends on lerped values.
With this in thoughts, the transition is actually detaching all planes from DOM monitoring -> tween their bounds/opacity/scale freely -> reattach to vacation spot web page’s DOM slots and proceed DOM monitoring.
Let’s dive into some specifics.
a. Render Loop
Every thing begins with a single entrypoint of src/index.js. The renderer is made and we’ve got one single requestAnimationFrame loop. We construct all of the picture textures for all of the pages at startup, so we would not have any latency in loading every web page’s textures when routing.
b. Pages
Every web page is only a perform that returns a string of HTML for brevity. Right here is the “Chosen” web page:
// pages/house.js
export perform house() {
const slots = [0, 1, 2, 3, 4]
.map(
(i) =>
`<a href="/<span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mrow><mi>i</mi><mo>+</mo><mn>1</mn></mrow><mi mathvariant="regular">"</mi><mi>d</mi><mi>a</mi><mi>t</mi><mi>a</mi><mo>−</mo><mi>l</mi><mi>i</mi><mi>n</mi><mi>ok</mi><mi>c</mi><mi>l</mi><mi>a</mi><mi>s</mi><mi>s</mi><mo>=</mo><mi mathvariant="regular">"</mi><mi>s</mi><mi>l</mi><mi>o</mi><mi>t</mi><mi>s</mi><mi>l</mi><mi>o</mi><mi>t</mi><mo>−</mo></mrow><annotation encoding="utility/x-tex">{i + 1}" data-link class="slot slot-</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" model="peak:0.7778em;vertical-align:-0.0833em;"></span><span class="mord"><span class="mord mathnormal">i</span><span class="mspace" model="margin-right:0.2222em;"></span><span class="mbin">+</span><span class="mspace" model="margin-right:0.2222em;"></span><span class="mord">1</span></span><span class="mord">"</span><span class="mord mathnormal">d</span><span class="mord mathnormal">a</span><span class="mord mathnormal">t</span><span class="mord mathnormal">a</span><span class="mspace" model="margin-right:0.2222em;"></span><span class="mbin">−</span><span class="mspace" model="margin-right:0.2222em;"></span></span><span class="base"><span class="strut" model="peak:0.6944em;"></span><span class="mord mathnormal" model="margin-right:0.01968em;">l</span><span class="mord mathnormal" model="margin-right:0.03148em;">ink</span><span class="mord mathnormal">c</span><span class="mord mathnormal" model="margin-right:0.01968em;">l</span><span class="mord mathnormal">a</span><span class="mord mathnormal">ss</span><span class="mspace" model="margin-right:0.2778em;"></span><span class="mrel">=</span><span class="mspace" model="margin-right:0.2778em;"></span></span><span class="base"><span class="strut" model="peak:0.7778em;vertical-align:-0.0833em;"></span><span class="mord">"</span><span class="mord mathnormal">s</span><span class="mord mathnormal" model="margin-right:0.01968em;">l</span><span class="mord mathnormal">o</span><span class="mord mathnormal">t</span><span class="mord mathnormal">s</span><span class="mord mathnormal" model="margin-right:0.01968em;">l</span><span class="mord mathnormal">o</span><span class="mord mathnormal">t</span><span class="mord">−</span></span></span></span>{i}"><determine></determine></a>`,
)
.be a part of('');
return `
<part data-page="primary" class="web page page-main">
<h1 class="page-title">Chosen</h1>
<div class="carousel">${slots}</div>
</part>
`;
}
A couple of issues I wish to level out:
- The
<determine>is empty. There isn’t a<img>wherever on the web page. That vacant field is a slot. It solely exists to carry area within the structure so I can measure the place the picture ought to be. The precise image is a WebGPU aircraft sitting on prime, monitoring that slot. data-pagetells the router what sort of web page that is (primary,inside,index). Each transition keys off that class, not off the URL.data-linkis the opt-in for the router. Any<a>carrying it will get intercepted as an alternative of triggering a full web page load.
The inside pages work the very same means. A perform that returns a stack of slots, one hero plus a number of supporting photographs. Similar concept, completely different structure.
The opposite half of a web page is a perform that measures these slots. When a transition must know the place a aircraft ought to fly to, it reads the vacation spot’s slots straight from the DOM:
// pages/house.js
export perform getMainTargets(rootEl) {
const slots = rootEl.querySelectorAll('.slot');
return Array.from(slots, (s) => {
const r = s.getBoundingClientRect();
return { x: r.left, y: r.prime, w: r.width, h: r.peak };
});
}
That is the entire trick to protecting the whole lot in sync: I by no means hardcode coordinates. CSS lays out the slots, getBoundingClientRect() tells me the place they landed, and the planes tween to these rects. Change the CSS and the transition nonetheless lands in the appropriate place.
c. The Router
The router is one class known as Controller and it does two jobs: it intercepts navigation, and it conducts the transition.
Let’s do navigation first.
It begins with a plain route desk declaring the routes:
const ROUTES = {
'/': { web page: 'primary', view: house, picture: null },
'/index': { web page: 'index', view: indexPage, picture: null },
'/1': { web page: 'inside', view: inside(0), picture: 0 },
'/2': { web page: 'inside', view: inside(1), picture: 1 },
// ...by means of /5
};
Every route is three issues: a web page class, the view perform that returns the HTML, and the picture index it foregrounds. Discover /1 by means of /5 all share web page: 'inside' as a result of the ‘inside’ web page is a template. The transitions care in regards to the class, not the precise URL.
To catch clicks, I put a single listener on the doc and filter for my hyperlinks:
onClick(e) {
const a = e.goal.closest('a[data-link]');
if (!a) return;
e.preventDefault(); // cease the total web page reload
this.navigate(a.getAttribute('href'));
}
onPopState() {
this.navigate(window.location.pathname, 'again'); // again/ahead button
}
- One listener on
doc, not one per hyperlink. Pages I inject later are coated routinely so there’s nothing to rebind. preventDefault()is the road that turns an actual hyperlink into an SPA navigation. The<a href>stays an actual, shareable, right-clickable URL; I simply hijack the left-click.popstatehandles the browser’s again and ahead buttons. I go a'again'flag sonavigateis aware of to not push one other historical past entry.
That’s the routing half. navigate() itself runs the transition, so I’ll cowl it within the subsequent part as a result of on this undertaking, navigation and the transition are the identical technique.
2. Web page Transitions
That is the meat of the tutorial. Every thing above was scaffolding so this part is smart.
Maintain, take away, add
Going again to the psychological mannequin, the planes are by no means created or destroyed. So a transition isn’t “construct the brand new photographs, tear down the previous ones.” Each aircraft on display is doing precisely one in every of three issues:
- Maintain: the picture exists on each pages, so I morph it: tween its bounds from the previous rect to the brand new rect. That is the seamless fly-and-scale.
- Take away: the picture is leaving. I fade its opacity to 0. The aircraft stays alive; it simply goes invisible and will get reused later.
- Add: the picture is new to the vacation spot. I stamp it at its goal rect (set bounds immediately, no tween) and fade opacity from 0 to 1, so it materializes in place as an alternative of flying in from nowhere.
// transitions/constants.js
export perform tweenBounds(aircraft, goal, opts = {}) {
return gsap.to(aircraft.bounds, { x: goal.x, y: goal.y, w: goal.w, h: goal.h, /* ... */ });
}
export perform tweenOpacity(aircraft, to, opts = {}) {
return gsap.to(aircraft, { opacity: to, /* ... */ });
}
tweenBounds is “maintain and transfer.” tweenOpacity is “take away or add.” Each transition within the undertaking is constructed from simply these two.
A transition consists of out() + in()
Every transition is a small class with two async strategies:
class SomeTransition {
async out(fromEl, toEl, ctx) { /* the planes that exist on the FROM web page */ }
async in(fromEl, toEl, ctx) { /* the planes which are new to the TO web page */ }
}
By conference out handles the planes leaving (morph the shared one, fade the remaining out), and in handles the planes arriving (stamp them, fade them up). The controller fires each without delay and waits for them collectively, so the 2 halves overlap and previous and new are in movement on the similar time. That overlap is what makes it learn as one steady transfer as an alternative of “previous leaves, then new arrives.”
Right here is the primary → inside transition. You’re on Chosen, you click on picture #2. Its hero ought to fly and develop into the inside web page’s lead slot, the opposite 4 fade out, and #2’s supporting photographs seem under:
// transitions/mainToInner.js
export class MainToInnerTransition {
async out(_from, toEl, ctx) {
const { gpu, toImage } = ctx;
const innerRects = getInnerTargets(toEl); // measure the vacation spot slots
const goal = innerRects[0]; // slot 0 = the hero place
const tweens = [];
for (let i = 0; i < MAIN_COUNT; i++) {
const aircraft = gpu.planes[mainIdx(i)];
if (i === toImage) {
tweens.push(tweenBounds(aircraft, goal)); // KEEP: fly the hero into place
} else {
tweens.push(tweenOpacity(aircraft, 0)); // REMOVE: fade the others out
}
}
await Promise.all(tweens);
}
async in(_from, toEl, ctx) {
const { gpu, toImage } = ctx;
const innerRects = getInnerTargets(toEl);
const fades = [];
for (let j = 0; j < SATELLITES_PER_IMAGE; j++) {
const sat = gpu.planes[satIdx(toImage, j)];
sat.bounds = { ...innerRects[j + 1] }; // ADD: stamp at its goal
sat.opacity = 0;
fades.push(tweenOpacity(sat, 1, { delay: 0.25 + j * 0.08 })); // ...then fade in, staggered
}
await Promise.all(fades);
}
}
Learn it in opposition to the three roles: one aircraft is stored and morphed, 4 are eliminated, 4 are added. Nothing will get constructed, nothing will get destroyed.
The one trick that makes it work
There’s a catch, and it’s an important element in the entire thing.
Usually a aircraft tracks its slot on each body, the render loop copies the slot’s getBoundingClientRect() into the aircraft’s bounds. That’s precisely what I need whilst you’re sitting on a web page. However throughout a transition I’m attempting to tween those self same bounds myself. If the aircraft had been nonetheless monitoring a slot, the render loop would overwrite my tween 60 instances a second and the morph can be invisible.
So the very first thing the controller does on the best way out is detach each aircraft from the DOM and unfreeze the bounds:
_leavePage(state) {
// ...cease the carousel, clear tilt, and so forth...
for (const aircraft of this.gpu.planes) {
aircraft.trackedEl = null; // ★ hand the bounds over to the tweens
}
}
With trackedEl cleared, nothing is combating the tween and GSAP owns each aircraft’s bounds till the transition finishes. As soon as it’s executed, I reattach the vacation spot’s planes to their slots and monitoring resumes. The seam is invisible as a result of the tween already ended precisely on the brand new slot’s rect. I tweened to getInnerTargets(toEl), which is that rect.
Placing it collectively: navigate()
Now navigate() reads prime to backside (trimmed barely for readability):
async navigate(path, goal = null) {
if (this.mutating) return; // 1. ignore clicks mid-transition
if (path === this.present?.path) return;
const subsequent = this.routes[path];
if (!subsequent) return;
const fromState = this.present;
const toState = { path, ...subsequent };
const transition = this._resolveTransition(fromState.web page, subsequent.web page);
this.mutating = true; // 2. lock
if (goal !== 'again') historical past.pushState({ path }, '', path); // 3. replace the URL
animateTitleOut(this.app.kids[0]); // 4. animate the previous web page's textual content out
this._leavePage(fromState); // 5. detach ALL planes from the DOM
this.app.insertAdjacentHTML('beforeend', subsequent.view()); // 6. inject dest. each pages coexist now
const fromEl = this.app.kids[0];
const toEl = this.app.lastElementChild;
// 7. put together the vacation spot's structure engine (carousel / index) so its goal rects exist
// 8. animate the brand new web page's textual content in
const ctx = { gpu: this.gpu, fromImage: fromState.picture, toImage: subsequent.picture, indexFloat: this.indexFloat };
const txOut = transition.out(fromEl, toEl, ctx); // 9. fireplace each halves without delay
const txIn = transition.in(fromEl, toEl, ctx);
await Promise.all([/* text tweens, */ txOut, txIn]); // 10. look forward to the whole lot
fromEl.take away(); // 11. drop the previous web page
this.present = toState;
this._snapLayout(toState); // lock the precise remaining opacities
this._enterPage(toState); // reattach monitoring + begin behaviors
this.mutating = false; // 12. unlock
}
Plainly:
lock → replace historical past → detach all planes → inject the brand new web page (each briefly on display) → fireplace
out()andin()collectively → await the whole lot → take away the previous web page → reattach monitoring → unlock.
A couple of choices value pointing at:
- The
mutatinglock makes the entire thing atomic. Mash the navigation hyperlinks and the second click on is ignored till the primary transition lands. In any other case two transitions would combat over the identical shared planes. - Each pages coexist for the length. The previous web page stays within the DOM (simply un-clickable) so its textual content can animate out and the structure doesn’t collapse whereas the planes are nonetheless flying. It’s eliminated solely after the
await. - The planes are indifferent the whole time. From step 5 to step 11 nothing is monitoring the DOM, so the tweens personal each bounds worth uncontested. The moment the transition resolves,
_enterPagereattaches and dwell monitoring picks up proper the place the tween left off.
5. Going Additional
That’s the core of it. A hard and fast pool of planes, a router that swaps the scaffolding, and transitions that maintain/take away/add by tweening bounds and opacity. As soon as that clicks, the whole lot else within the supply is a variation on the identical concept:
- The carousel on Chosen doesn’t transfer planes in any respect. It strikes the DOM slots and lets the planes observe them, so the GPU aspect wants zero carousel-specific code.
- The index web page drives bounds a unique means: it tasks the planes in 3D and writes the outcome straight into
bounds, however the transition nonetheless simplytweenBoundsinto these rects. - The textual content (titles, details, captions) animates out and in with GSAP SplitText alongside the planes, awaited collectively so the whole lot lands without delay.
Add your personal web page class, write a view + a getTargets measurer, register a transition constructed from those self same two verbs, and it drops proper in. With a bit of tuning on the eases and timings, you possibly can take this a great distance.
You possibly can dig by means of the total supply for the carousel, the index projection, the customized cursor, and the shader that attracts the rounded corners.
Please share what you suppose and observe me on Twitter and Instagram.


