Hey! I’m Valentin Mor, a frontend inventive developer based mostly in Paris. On this tutorial, we’ll construct a light-weight async web page transition system from scratch utilizing vanilla JavaScript, GSAP, and Vite.
By the top, you’ll have a completely purposeful SPA router with crossfade transitions between pages — no framework required.
Introduction
It might sound shocking, however after I take into consideration the hallmarks of inventive web sites as a developer, the very first thing that involves thoughts is how routing and web page transitions are dealt with. Typically all you want is a straightforward fade-out/fade-in impact, however including a contact of depth and movement can considerably enhance the person expertise.
I spent quite a lot of time exploring this matter utilizing libraries reminiscent of Barba.js to know what occurs behind the scenes — particularly when the present web page and the subsequent web page briefly coexist within the DOM.
I can’t go any additional with out mentioning Aristide Benoist — a real reference relating to easy, cinematic web page transitions. The transition we’re constructing right here is impressed by his work on the Watson web site. Should you’re not accustomed to it, I extremely encourage you to test it out.
What We’re Constructing
A minimal single-page software with:
- A customized client-side router that intercepts hyperlink clicks and manages navigation utilizing the Historical past API.
- An async transition engine that animates the present and subsequent pages concurrently.
Right here’s the important thing thought: As a substitute of immediately swapping the web page content material, we clone the web page container, inject the brand new content material into the clone, animate each containers (outdated out, new in), after which take away the outdated one. This creates true crossfade transitions, the place each pages coexist within the DOM through the animation.
Venture Setup
Open your favourite IDE and run the next command: npm create vite@newest
When prompted, choose Vanilla because the framework and JavaScript because the variant.
Clear up the preliminary recordsdata by deleting the counter.js file from the src folder, and preserve solely the type import in your foremost.js.
yourproject/
├── node_modules/
├── public/
├── src/
│ ├── foremost.js
│ └── type.css
├── gitignore
├── index.html
├── package-lock.json
└── package deal.json
Step 1: The HTML Shell
Our index.html file serves as the basis structure — the everlasting shell that persists throughout navigations. Solely the content material inside data-transition="container" adjustments.
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta identify="viewport" content material="width=device-width, initial-scale=1.0" />
<title>Constructing Async Web page Transitions in Vanilla JavaScript</title>
</head>
<physique>
<div data-transition="wrapper">
<div data-transition="container" data-namespace="dwelling">
<foremost id="page_content" class="page_content"></foremost>
</div>
</div>
<script sort="module" src="/src/foremost.js"></script>
</physique>
</html>
Three knowledge attributes do the heavy lifting:
data-transition="wrapper"— The guardian ingredient that holds each the present and incoming containers throughout a transition. Each pages stay right here concurrently.data-transition="container"— Cloned for every new web page. Throughout a transition, the wrapper quickly accommodates two of those parts.data-namespace— Identifies which web page is presently displayed. On this tutorial, it’s primarily helpful for debugging, however in additional superior initiatives, it turns into important for mapping totally different transition animations to particular page-to-page routes.
Step 2: Web page Modules
Begin by making a /pages folder inside src, then add your first two web page folders: /dwelling and /alternative-page.
Every of those folders will comprise one HTML file and one JavaScript file.
├── pages/
│ ├── dwelling/
│ │ ├── dwelling.html
│ │ └── dwelling.js
│ └── alternative-page/
│ ├── alternative-page.html
│ └── alternative-page.js
Write some minimal HTML with a <part> wrapper, an <h1>, and a <nav> containing hyperlinks to our two web site pages: “/” and “/alternative-page“.
<part class="hero">
<nav>
<a href="/alternative-page">Various web page</a>
</nav>
<div class="hero_content">
<h1>AH.736</h1>
</div>
</part>
Every web page is a self-contained module that exports a default operate returning HTML.
For the sake of completeness, add an init() operate in your JavaScript setup (reminiscent of occasion listeners), and an non-obligatory cleanup() operate for teardown.
We’ll solely use the default operate within the core of this tutorial.
The ?uncooked suffix is a Vite characteristic that imports the HTML file as a uncooked string. The corresponding template is pure HTML:
import template from "./dwelling.html?uncooked";
export default operate HomePage() {
return template;
}
export operate init() {}
export operate cleanup() {}
For now, add some minimal styling to maintain the web page content material at full-screen top, import your favourite font, and begin including some primary types of your alternative.
@font-face {
font-family: "Neue";
src: url("/NeueMontreal-Medium.ttf");
font-weight: 600;
font-style: regular;
font-display: swap;
}
:root {
font-family: Neue;
line-height: 1;
colour: rgb(0, 0, 0);
background-color: #000000;
}
*,
*::earlier than,
*::after {
box-sizing: border-box;
}
physique {
font-family: Neue;
margin: 0;
show: flex;
overflow-x: hidden;
min-height: 100vh;
}
foremost {
width: 100vw;
}
h1 {
font-size: 28.2vw;
margin: 0;
line-height: 80%;
}
a {
colour: black;
text-decoration: none;
text-transform: uppercase;
}
.hero {
background-color: white;
width: 100%;
top: 100vh;
overflow: hidden;
show: flex;
justify-content: space-between;
flex-direction: column;
padding: 20px;
align-items: middle;
}
.hero_content {
width: 100%;
padding-top: 8vh;
text-align: middle;
}
[data-transition="container"] {
remodel: translateZ(0);
backface-visibility: hidden;
}
Be aware: We gained’t go into styling or advanced enter animations, as this matter is already fairly dense. I like to recommend sticking to the naked minimal for now, and later bettering your undertaking with polished styling and animations.
Step 3: The Router
The router is the mind of our SPA. It intercepts hyperlink clicks, manages browser historical past, dynamically hundreds web page modules, and orchestrates transitions between pages.
Let’s construct it piece by piece.
Contained in the src folder, create a router.js file.
Route Definitions
Every route maps a URL path to 2 issues: a namespace — a string identifier that labels every web page.
The transition engine will use this to find out which web page is getting into and which is leaving.
loader is an arrow operate that wraps a dynamic import().
The module is simply fetched from the community once we truly name loader().
On the primary name, the browser downloads and parses the module; on subsequent calls, it immediately returns the cached model.
const routes = {
"/": {
namespace: "dwelling",
loader: () => import("./pages/dwelling/dwelling.js"),
},
"/alternative-page": {
namespace: "alternative-page",
loader: () => import("./pages/alternative-page/alternative-page.js"),
},
};
Class Construction
After defining the routes, let’s create a Router class to handle our navigation state. It holds two properties:
currentNamespacetracks which web page is presently displayed, permitting us to skip same-page navigations.isTransitioningis a lock that stops double navigations when customers click on quickly.
On the backside of the file, we export an occasion of the category.
class Router {
constructor() {
this.currentNamespace = null;
this.isTransitioning = false;
}
// Write your capabilities right here
}
export const router = new Router();
Let’s add the Router’s capabilities — every operate serves a selected goal.
loadInitialPage()
This technique runs as soon as on boot.
- Match the present URL to a route.
- Get the present path.
- Evaluate the trail with our
routesarray to lazy-load the right module. - Load the module.
- Inject its HTML into the
#page_contentingredient that already exists in our HTML shell. - Set the
namespaceon the container to trace which web page is presently displayed.
No transition animation right here — the web page merely seems.
async loadInitialPage() {
// Match the present URL to a route
const path = window.location.pathname;
const route = routes[path]
// Dynamically import the web page module
const pageModule = await route.loader();
// Inject the web page's HTML template into the present DOM shell
const content material = doc.getElementById("page_content");
content material.innerHTML = pageModule.default();
// Tag the container with the present namespace
const container = doc.querySelector('[data-transition="container"]');
container.setAttribute("data-namespace", route.namespace);
// Retailer references
this.currentNamespace = route.namespace;
}
navigate()
This operate runs when a person clicks a hyperlink. At this stage, our navigate() technique handles the total web page swap inline — there’s no transition engine but.
Let’s stroll via what occurs when a person clicks a hyperlink:
- Guard clauses verify whether or not we’re already transitioning or whether or not the clicked hyperlink factors to the present web page.
- We then replace the URL.
- We resolve the route and dynamically import the web page module. Then we carry out a direct
innerHTMLswap — the identical logic asloadInitialPage(), however triggered by person navigation as an alternative of the preliminary load.
async navigate(path) {
// Guard clauses
if (this.isTransitioning || window.location.pathname === path) return;
// Replace the URL within the deal with bar with out triggering a web page reload
window.historical past.pushState({}, "", path);
// Resolve the matching route
const route = routes[path]
// Dynamically import the subsequent web page module
const pageModule = await route.loader();
// Swap the HTML content material immediately — no animation but
const content material = doc.getElementById("page_content");
content material.innerHTML = pageModule.default();
// Replace the namespace
const container = doc.querySelector('[data-transition="container"]');
container.setAttribute("data-namespace", route.namespace);
// Replace inner state for the subsequent navigation cycle
this.currentNamespace = route.namespace;
}
Within the subsequent step, we’ll extract this swap logic right into a devoted transition engine and substitute the inline swap with an animated performTransition() name — however first, we wanted to ensure the plumbing was strong.
init()
init() does three issues, so as:
- First, it hundreds the preliminary web page — no matter URL the person landed on. We
awaitthis so the primary web page is totally rendered earlier than we begin listening for clicks. - Then, it registers a
international click on listenerondoc. As a substitute of including occasion listeners to each<a>tag (which might break when new hyperlinks are injected dynamically throughout transitions), we use occasion delegation: each click on bubbles as much asdoc, and we verify whether or not it originated from an anchor tag utilizingclosest("a"). - We filter out exterior hyperlinks by checking
startsWith(window.location.origin). - We forestall the browser’s default navigation with
e.preventDefault(), and guard in opposition to mid-transition clicks utilizing ourisTransitioninglock.
async init() {
// Load and render no matter web page matches the present URL
await this.loadInitialPage();
// International click on listener utilizing occasion delegation
doc.addEventListener("click on", (e) => );
}
Be aware: This can be a very minimal setup — we’re not dealing with popstate occasions but.
Alright, let’s verify whether or not our primary routing engine is working accurately. Go to your foremost.js file, import the router, and name its init() operate.
import "./type.css";
import { router } from "./router.js";
router.init();
Run your native server with npm run dev and click on the highest navigation hyperlinks — it is best to be capable to navigate easily between your pages.
We’re making nice progress!
Step 4: The Transition Engine
For now, we are able to go away the router file (we’ll come again to it shortly) and create a /transitions folder inside /src.
This folder will comprise:
- An
/animationsfolder that may embody our future animation timeline recordsdata for web page transitions. - A
pageTransition.jsfile.
├── transitions/
│ ├── animations/
│ │ └── defaultTransition.js
│ └── pageTransitions.js
Earlier than creating our easy transition engine, let’s construct the animation timeline for the transition.
We’re going to make use of GSAP for the animations.
Set up GSAP with the next command: npm set up gsap
I extremely advocate making a /lib folder containing an index.js file with all of your library imports and exports — this offers you a single entry level for heavy dependencies.
import { gsap } from "gsap";
export { gsap };
Now let’s write the transition animation itself.
The impact we’re after: the present web page interprets barely upward with a refined fade, whereas the subsequent web page concurrently reveals from backside to high utilizing a clip-path animation. Each occur on the similar time, making a layered reveal.
The important thing trick: we set the subsequent container to place: fastened so it stacks on high of the present web page. Mixed with the preliminary clip-path state, the subsequent web page is totally hidden — then we animate the clip-path for a clear reveal.
For a cool fade-out impact, set the background-color of your physique ingredient to black.
The animation operate receives two parameters: currentContainer and nextContainer, and returns the animation timeline.
import { gsap } from "../../lib/index.js";
export async operate defaultTransition(currentContainer, nextContainer) {
gsap.set(nextContainer, {
clipPath: "inset(100% 0% 0% 0%)",
opacity: 1,
place: "fastened",
high: 0,
left: 0,
width: "100%",
top: "100vh",
zIndex: 10,
willChange: "remodel, clip-path",
});
const tl = gsap.timeline();
tl.to(
currentContainer,
{
y: "-30vh",
opacity: 0.6,
force3D: true,
period: 1,
ease: "power2.inOut",
},
0,
)
.fromTo(
nextContainer,
{ clipPath: "inset(100% 0% 0% 0%)" },
{
clipPath: "inset(0% 0% 0% 0%)",
period: 1,
force3D: true,
ease: "power2.inOut",
},
0,
);
return tl;
}
pageTransition()
Let’s return to our pageTransition.js file.
That is the core of the async sample. The engine creates a second container, injects the subsequent web page into it, runs an animation between each containers, after which cleans up.
First, import the GSAP occasion and our newly created animation operate.
The operate solely receives nextHTML — that’s all it wants. Let’s break down the circulation:
- We question the present container and its guardian wrapper.
- Then we name
cloneNode(false)— thefalseargument means we clone the ingredient itself (similar tag, similar attributes) however with out its kids. - We create a
<foremost>ingredient, inject the subsequent web page’s HTML into it, and append it to our cloned container. Then we append that container to the wrapper. - We cross each containers to
defaultTransition(), which returns aGSAP timeline. Theawait timeline.then()line pauses execution till each tween within the timeline has accomplished. - Lastly, we clear up: take away the outdated container from the DOM and clear all of the inline types GSAP injected through the animation (
place: fastened,clip-path,opacity, and many others.).
import { gsap } from "../lib/index.js";
import { defaultTransition } from "./animations/defaultTransition.js";
export async operate executeTransition({ nextHTML }) {
const currentContainer = doc.querySelector(
'[data-transition="container"]',
);
const wrapper = doc.querySelector('[data-transition="wrapper"]');
// Clone the container construction for the subsequent web page
const nextContainer = currentContainer.cloneNode(false);
const content material = doc.createElement("foremost");
content material.id = "page_content";
content material.className = "page_content";
content material.innerHTML = nextHTML;
nextContainer.appendChild(content material);
// Append subsequent web page to DOM — each pages now coexist
wrapper.appendChild(nextContainer);
// Run the transition animation
const timeline = defaultTransition(currentContainer, nextContainer);
// Anticipate animation to finish
await timeline.then();
// Cleanup: take away outdated web page, reset transforms
currentContainer.take away();
gsap.set(nextContainer, { clearProps: "all" });
gsap.set(nextContainer, { force3D: true });
}
Whereas the transition is operating, our DOM will appear like this:
<div data-transition="wrapper">
<!-- Present web page (animating out) -->
<div data-transition="container" data-namespace="dwelling">
<foremost id="page_content" class="page_content">
<!-- dwelling HTML -->
</foremost>
</div>
<!-- Subsequent web page (animating in) -->
<div data-transition="container" data-namespace="about">
<foremost id="page_content" class="page_content">
<!-- about HTML -->
</foremost>
</div>
</div>
Now we return to our router.js file and implement the brand new pageTransition() logic.
Let’s create a devoted technique for this.
First, import the executeTransition() operate we simply created.
We’ll substitute the direct innerHTML swap in navigate() with a correct performTransition() technique, and add a popstate handler.
performTransition()
The operate takes one parameter: the path.
The circulation intimately:
- Block execution if the
isTransitioningflag is energetic or if we’re already on the identical web page. - Dynamically import the subsequent web page
module. - Run the async transition utilizing
executeTransition(). - Replace the present
namespaceand reset theisTransitioningflag tofalseas soon as theexecuteTransition()timeline has accomplished.
async performTransition(path) {
// Block if a transition is already operating
if (this.isTransitioning) return;
this.isTransitioning = true;
attempt {
// Resolve the matching route
const route = routes[path];
if (!route || this.currentNamespace === route.namespace) return;
// Dynamically import the subsequent web page module
const pageModule = await route.loader();
// Run the async transition — that is the place each pages
// coexist within the DOM and animate concurrently
await executeTransition({
nextHTML: pageModule.default(),
});
// Replace inner state for the subsequent navigation cycle
this.currentNamespace = route.namespace;
} lastly {
// Launch the lock it doesn't matter what — even when an error happens
this.isTransitioning = false;
}
}
Replace navigate()
navigate() now solely handles the URL replace utilizing pushState and delegates the whole lot else to performTransition().
async navigate(path) {
if (window.location.pathname === path || this.isTransitioning) return;
window.historical past.pushState({}, "", path);
await this.performTransition(path);
}
popstate Occasions
Now we are able to correctly add a popstate handler. For the reason that browser has already up to date the URL when the popstate occasion fires, we name performTransition() immediately — no pushState wanted:
Contained in the init() operate, add an occasion listener for popstate occasions.
window.addEventListener("popstate", () => {
if (!this.isTransitioning) {
this.performTransition(window.location.pathname);
}
});
That is precisely why we separated navigate() and performTransition():
navigate() is for programmatic navigation (click on → pushState → transition), whereas performTransition() is used when the URL has already modified (popstate → transition solely).
Be aware: Within the click on listener, we extract the trail from the hyperlink’s href attribute utilizing new URL(hyperlink.href).pathname, whereas within the popstate handler, we learn it from window.location.pathname. Identical consequence, totally different sources: on click on, we all know the place the person desires to go from the hyperlink; on popstate, the browser has already up to date the URL, so we merely learn the present location.
Congrats — your transition engine works!
Every little thing is working nice, however let’s add a really primary enter animation to provide the movement extra depth.
Step 5: Enter Animations
Create an /animations folder inside src and add an enter.js file.
Let’s animate our <h1> from a optimistic y offset again to its unique place.
We have to goal the right title, since two <h1> parts exist whereas the transition is operating. So the operate will take two parameters: delay for fine-tuning the animation, and nextContainer.
import { gsap } from "../lib";
const ENTER = (nextContainer, delay) => {
const t = nextContainer?.querySelector("h1");
if (!t) return null;
gsap.set(t, { y: "100%" });
const tl = gsap.timeline({
delay,
});
tl.to(
t,
{
y: 0,
period: 1.2,
force3D: true,
ease: "expo.out",
},
0,
);
return { timeline: tl };
};
export default ENTER;
Name this animation operate contained in the init() operate of each web page modules.
import template from "./about.html?uncooked";
import ENTER from "../../animations/Enter";
export default operate AboutPage() {
return template;
}
export operate init({ container }) {
ENTER(container, 0.45);
}
export operate cleanup() {}
Now we have to name init() on the similar time we name executeTransition() inside performTransition().
Replace this half:
await executeTransition({
nextHTML: pageModule.default(),
nextModule: pageModule,
});
Now executeTransition() receives nextModule and calls its init() technique if it exists:
if (nextModule?.init) {
nextModule.init({ container: nextContainer });
}
That appears a lot better now!
Going Additional
What we’ve constructed is a completely purposeful but minimal system. It covers the core mechanics — routing, dual-container DOM administration, and animated transitions — however a production-ready implementation would want to deal with a number of extra points.
- Web page lifecycle hooks.
- Aborting mid-transition.
- Prefetching on hover.
- Updating meta tags.
And the record may go on and on…
The fantastic thing about constructing this from scratch is that each piece is yours to know, personal, and prolong.
With a little bit fine-tuning and experimentation, you may obtain some actually cool outcomes:


