Hey! I’m Valentin Mor, a frontend artistic 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 useful SPA router with crossfade transitions between pages — no framework required.
Introduction
It might sound shocking, however after I take into consideration the hallmarks of artistic web sites as a developer, the very first thing that involves thoughts is how routing and web page transitions are dealt with. Generally all you want is an easy fade-out/fade-in impact, however including a contact of depth and movement can considerably enhance the person expertise.
I spent a whole lot of time exploring this subject utilizing libraries equivalent to 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 clean, 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 concept: As an alternative 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 (previous out, new in), after which take away the previous one. This creates true crossfade transitions, the place each pages coexist within the DOM in the course of the animation.
Undertaking 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 information by deleting the counter.js file from the src folder, and hold solely the fashion import in your principal.js.
yourproject/
├── node_modules/
├── public/
├── src/
│ ├── principal.js
│ └── fashion.css
├── gitignore
├── index.html
├── package-lock.json
└── bundle.json
Step 1: The HTML Shell
Our index.html file serves as the foundation format — the everlasting shell that persists throughout navigations. Solely the content material inside data-transition="container" adjustments.
Constructing Async Web page Transitions in Vanilla JavaScript
Three information attributes do the heavy lifting:
data-transition="wrapper"— The mum or dad aspect that holds each the present and incoming containers throughout a transition. Each pages reside right here concurrently.data-transition="container"— Cloned for every new web page. Throughout a transition, the wrapper briefly comprises two of those parts.data-namespace— Identifies which web page is at the moment displayed. On this tutorial, it’s primarily helpful for debugging, however in additional superior tasks, it turns into important for mapping completely 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: /residence and /alternative-page.
Every of those folders will include one HTML file and one JavaScript file.
├── pages/
│ ├── residence/
│ │ ├── residence.html
│ │ └── residence.js
│ └── alternative-page/
│ ├── alternative-page.html
│ └── alternative-page.js
Write some minimal HTML with a
, and a
containing hyperlinks to our two web site pages: “/” and “/alternative-page“.
Every web page is a self-contained module that exports a default operate returning HTML.
For the sake of completeness, add an init() operate to your JavaScript setup (equivalent to 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 function that imports the HTML file as a uncooked string. The corresponding template is pure HTML:
import template from "./residence.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 peak, 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;
coloration: 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;
}
principal {
width: 100vw;
}
h1 {
font-size: 28.2vw;
margin: 0;
line-height: 80%;
}
a {
coloration: black;
text-decoration: none;
text-transform: uppercase;
}
.hero {
background-color: white;
width: 100%;
peak: 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"] {
rework: translateZ(0);
backface-visibility: hidden;
}
Notice: We gained’t go into styling or complicated enter animations, as this subject is already fairly dense. I like to recommend sticking to the naked minimal for now, and later bettering your challenge 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 really 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: "residence",
loader: () => import("./pages/residence/residence.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 at the moment displayed, permitting us to skip same-page navigations.isTransitioningis a lock that forestalls 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 features right here
}
export const router = new Router();
Let’s add the Router’s features — every operate serves a selected function.
loadInitialPage()
This methodology runs as soon as on boot.
- Match the present URL to a route.
- Get the present path.
- Examine the trail with our
routesarray to lazy-load the proper module. - Load the module.
- Inject its HTML into the
#page_contentaspect that already exists in our HTML shell. - Set the
namespaceon the container to trace which web page is at the moment 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() methodology handles the total web page swap inline — there’s no transition engine but.
Let’s stroll by means of what occurs when a person clicks a hyperlink:
- Guard clauses examine 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 instantly — 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 inside 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 exchange the inline swap with an animated performTransition() name — however first, we wanted to verify 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
world click on listenerondoc. As an alternative of including occasion listeners to eachtag (which might break when new hyperlinks are injected dynamically throughout transitions), we use occasion delegation: each click on bubbles as much asdoc, and we examine whether or not it originated from an anchor tag utilizingclosest("a"). - We filter out exterior hyperlinks by checking
startsWith(window.location.origin). - We stop the browser’s default navigation with
e.preventDefault(), and guard towards 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) => );
}
Notice: This can be a very minimal setup — we’re not dealing with popstate occasions but.
Alright, let’s examine whether or not our primary routing engine is working appropriately. Go to your principal.js file, import the router, and name its init() operate.
import "./fashion.css";
import { router } from "./router.js";
router.init();
Run your native server with npm run dev and click on the highest navigation hyperlinks — it's best to be capable of navigate easily between your pages.
We’re making nice progress!
Step 4: The Transition Engine
For now, we will go away the router file (we’ll come again to it shortly) and create a /transitions folder inside /src.
This folder will include:
- An
/animationsfolder that can embrace our future animation timeline information 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 provides 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 identical time, making a layered reveal.
The important thing trick: we set the subsequent container to place: mounted 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 aspect 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: "mounted",
high: 0,
left: 0,
width: "100%",
peak: "100vh",
zIndex: 10,
willChange: "rework, clip-path",
});
const tl = gsap.timeline();
tl.to(
currentContainer,
{
y: "-30vh",
opacity: 0.6,
force3D: true,
length: 1,
ease: "power2.inOut",
},
0,
)
.fromTo(
nextContainer,
{ clipPath: "inset(100% 0% 0% 0%)" },
{
clipPath: "inset(0% 0% 0% 0%)",
length: 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 move:
- We question the present container and its mum or dad wrapper.
- Then we name
cloneNode(false)— thefalseargument means we clone the aspect itself (identical tag, identical attributes) however with out its youngsters. - We create a
aspect, 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 previous container from the DOM and clear all of the inline types GSAP injected in the course of the animation (
place: mounted,clip-path,opacity, and so forth.).
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("principal");
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);
// Look ahead to animation to finish
await timeline.then();
// Cleanup: take away previous 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 to be this:
Now we return to our router.js file and implement the brand new pageTransition() logic.
Let’s create a devoted methodology for this.
First, import the executeTransition() operate we simply created.
We’ll exchange the direct innerHTML swap in navigate() with a correct performTransition() methodology, and add a popstate handler.
performTransition()
The operate takes one parameter: the path.
The move 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;
strive {
// 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 inside 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 all the pieces 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 will 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() instantly — 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).
Notice: 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 end result, completely different sources: on click on, we all know the place the person needs 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!
The whole lot is working nice, however let’s add a really primary enter animation to present the movement extra depth.
Step 5: Enter Animations
Create an /animations folder inside src and add an enter.js file.
Let’s animate our from a optimistic y offset again to its authentic place.
We have to goal the proper title, since two 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,
length: 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 identical time we name executeTransition() inside performTransition().
Replace this half:
await executeTransition({
nextHTML: pageModule.default(),
nextModule: pageModule,
});
Now executeTransition() receives nextModule and calls its init() methodology if it exists:
if (nextModule?.init) {
nextModule.init({ container: nextContainer });
}
That appears significantly better now!
Going Additional
What we’ve constructed is a completely useful 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 features.
- Web page lifecycle hooks.
- Aborting mid-transition.
- Prefetching on hover.
- Updating meta tags.
And the record may go on and on…
The great thing about constructing this from scratch is that each piece is yours to know, personal, and prolong.
With a bit fine-tuning and experimentation, you'll be able to obtain some actually cool outcomes:









