Please enable JavaScript to view this website.

Page Transitions with Highway.js & 11ty

How to create smooth, fluid page transitions like this with Highway.js, GSAP and Eleventy.

04.04.2022

Open the terminal, create a new folder and install the dependencies.

mkdir 11ty-highway
cd 11ty-highway/
npm init -y

npm install --save-dev @11ty/eleventy
npm install @dogstudio/highway
npm install gsap
npm install esbuild

Run eleventy --serve and you already have an empty website running! But let's define two scripts in package.json, that will run our website on http://localhost:8080/ and bundle the JavaScript. You need to use npm start now. I also like to keep my code inside a src folder.

"scripts": {
"start": "eleventy --serve --watch & esbuild ./src/main.js --outfile=_site/main.js --bundle --watch",
"build": "eleventy && esbuild ./src/main.js --outfile=_site/main.js --bundle"
},

Layout

For each of our pages we need a base layout. Create src/_includes/layout.njk. I use Nunjucks (.njk) as a template engine.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Page Transitions with Highway.js and 11ty</title>
<link rel="stylesheet" href="/index.css">
</head>
<body>
<div data-router-wrapper>
<div data-router-view="global" data-template="{{ template }}">
{% include "components/nav.njk" %}

<main>
{{ content | safe }}
</main>
</div>
</div>

<script src="/main.js"></script>
</body>
</html>

The content goes into <main /> and Highway requires two wrappers data-router-wrapper and data-router-view="global". I have also added a Navigation component {% include "components/nav.njk" %}. Create src/_includes/components/nav.njk for this. Eleventy automatically detects all the files inside _includes and you can structure your code into components and sections super easily using Nunjucks' include syntax.

<nav>
<a class="nav-item" href="/about">About</a>
<a class="nav-item" href="/work">Work</a>
</nav>

Eleventy Configuration

I also want to use images, which I'm storing in src/assets. For Eleventy to copy the photos into the output (_site) folder, create a new config file .eleventy.js in your root directory. We also need to let Eleventy know that the input folder is src and that it should register Nunjucks and CSS files.

module.exports = function(eleventyConfig) {
eleventyConfig.addPassthroughCopy('src/assets');
eleventyConfig.setTemplateFormats(['njk', 'css']);

return {
dir: {
input: 'src',
},
}
};

Create some pages

index.njk

---
layout: layout.njk
---

<section class="index-wrapper">
<h1>Hello</h1>

<div class="photos">
<a href="/about" class="img-1">
<figure>
<img src="/assets/1.jpg" alt="">
</figure>
</a>

<a href="/work" class="img-2">
<figure>
<img src="/assets/2.jpg" alt="">
</figure>
</a>
</div>
</section>

about.njk

---
layout: layout.njk
template: about
---

<a href="/" class="logo">
<h1>Hello</h1>
</a>

<figure class="fullscreen-img">
<img src="/assets/1.jpg" alt="">
</figure>

work.njk

---
layout: layout.njk
template: work
---

<a href="/" class="logo">
<h1>Hello</h1>
</a>

<figure class="fullscreen-img">
<img src="/assets/2.jpg" alt="">
</figure>

Page Transitions

Create a new file src/main.js and src/transitions/DefaultTransition.js. In main.js we initialize Highway.js and add a global transition (DefaultTransition). Its name (global) must match the name of data-router-view="global" in layout.njk. You can choose any name you like for this.

As a default transition, I'm fading the main content in and out with gsap.

// main.js

import Highway from '@dogstudio/highway';
import { DefaultTransition } from './transitions/DefaultTransition';

new Highway.Core({
transitions: {
global: DefaultTransition,
},
});
// DefaultTransition.js

import Highway from '@dogstudio/highway';
import gsap from 'gsap';

export class DefaultTransition extends Highway.Transition {
in({ from, done }) {
window.scrollTo(0, 0);
from.remove();

gsap.fromTo('main',
{
autoAlpha: 0,
},
{
autoAlpha: 1,
duration: 0.4,
ease: 'expo.inOut',
onComplete: done,
});
}

out({ done }) {
gsap.to('main', {
autoAlpha: 0,
duration: 0.2,
ease: 'power4.out',
onComplete: done,
});
}
}

However, with Highway.js you can make as many and as different transitions as you want. For example, if the transition from a work overview page to the project detail page should be a special one. Let's adjust main.js for this and add a CustomTransition, the name (custom) is again up to you.

import Highway from '@dogstudio/highway';
import { CustomTransition } from './transitions/CustomTransition';
import { DefaultTransition } from './transitions/DefaultTransition';

new Highway.Core({
transitions: {
global: DefaultTransition,
contextual: {
custom: CustomTransition,
},
},
});

By clicking on one of the images on the frontpage, I'd like to trigger a different transition. We just have to let the link, that should trigger the special transition, know which one it should be. You need to add data-transition="custom" for that. Also, I duplicated the images as transition-asset and positioned them absolute – we'll need that for the transition. That's how index.njk now looks like:

---
layout: layout.njk
---

<section class="index-wrapper">
<h1>Hello</h1>

<div class="photos">
<a href="/about" class="img-1" data-transition="custom">
<figure>
<img src="/assets/1.jpg" alt="">
</figure>
<figure class="transition-asset">
<img src="/assets/1.jpg" alt="">
</figure>
</a>

<a href="/work" class="img-2" data-transition="custom">
<figure>
<img src="/assets/2.jpg" alt="">
</figure>
<figure class="transition-asset">
<img src="/assets/2.jpg" alt="">
</figure>
</a>
</div>
</section>

Custom Transition

// CustomTransition.js

import Highway from '@dogstudio/highway';
import gsap from 'gsap';

export class CustomTransition extends Highway.Transition {
in({ from, done }) {
window.scrollTo(0, 0);
from.remove();

gsap.to('main', {
duration: 0,
autoAlpha: 1,
ease: 'power4.inOut',
onComplete: done,
});
}

out({ trigger, done }) {
const asset = trigger.querySelector('.transition-asset');

gsap.set(asset, { zIndex: 1 });
gsap.to(asset, {
width: window.innerWidth,
height: '100vh',
duration: 0.8,
ease: 'power4.out',
onComplete: done
});
}
}

First, I want to scale the photo that was clicked to fill the screen. Highway provides the trigger property, which I'm using to select the duplicated transition asset that was clicked. Then we scale the height and width to the entire viewport, so the transitions looks like this:

To make the image overlay the entire content, I simply measure the distance to the edge of the viewport with getBoundingClientRect() and transform the y and x coordinates accordingly. Since the transition asset is absolutely positioned and just a clone it causes no layout shift!

The position of the logo is also changed so that it goes beyond the top of the viewport.

import Highway from '@dogstudio/highway';
import gsap from 'gsap';

export class CustomTransition extends Highway.Transition {
in({ from, done }) {
window.scrollTo(0, 0);
from.remove();

gsap.to('main', {
duration: 0,
autoAlpha: 1,
ease: 'power4.inOut',
onComplete: done,
});
}

out({ trigger, done }) {
const asset = trigger.querySelector('.transition-asset');
const logo = document.querySelector('h1');

gsap.to(logo, { y: -120, duration: 0.7, ease: 'expo.out', delay: 0.1 })

const { top, left } = trigger.getBoundingClientRect();

gsap.set(asset, { zIndex: 1 });
gsap.to(asset, {
width: window.innerWidth,
height: '100vh',
y: top * -1,
x: left * -1,
duration: 0.8,
ease: 'power4.out',
onComplete: done
});
}
}

There is one more problem. When the page changes, the image flashes. To fix this, I defined a variable outside the transition function and created a new Image() with JavaScript. The src of this new image, is set to the already loaded source of the asset.

Inside the in() {} function, the new Image is appended to the container of the existing photo.

As a last thing I use the template dataset (defined in layout.njk and the frontmatter of each page) to check if the current page is the about page and if so, change the color of the header to white.

import Highway from '@dogstudio/highway';
import gsap from 'gsap';

let loadedImg;

export class CustomTransition extends Highway.Transition {
in({ from, done }) {
window.scrollTo(0, 0);
from.remove();

const imgContainer = document.querySelector('.fullscreen-img')
imgContainer.appendChild(loadedImg);

const template = document.querySelector('[data-template]').dataset.template;
gsap.to(['h1', '.nav-item'], { color: template === 'about' ? 'white' : '', duration: 0.2 })

gsap.to('main', {
duration: 0,
autoAlpha: 1,
ease: 'power4.inOut',
onComplete: done,
});
}

out({ trigger, done }) {
const asset = trigger.querySelector('.transition-asset');
const logo = document.querySelector('h1');
const img = asset.querySelector('img');

loadedImg = new Image();
loadedImg.src = img.src;
gsap.set(loadedImg, { position: 'absolute', inset: 0, zIndex: -1 });

gsap.to(logo, { y: -120, duration: 0.7, ease: 'expo.out', delay: 0.1 })

const { top, left } = trigger.getBoundingClientRect();

gsap.set(asset, { zIndex: 1 });
gsap.to(asset, {
width: window.innerWidth,
height: '100vh',
y: top * -1,
x: left * -1,
duration: 0.8,
ease: 'power4.out',
onComplete: done
});
}
}

Renderer

If you want to use JavaScript on the different pages, you also have to add a Renderer.

Base.js

import Highway from '@dogstudio/highway';
import { Base } from './renderer/Base';
import { DefaultTransition } from './transitions/DefaultTransition';

new Highway.Core({
renderers: {
global: Base,
},
transitions: {
global: DefaultTransition,
contextual: {
custom: CustomTransition,
},
},
});
import Highway from '@dogstudio/highway';

export class Base extends Highway.Renderer {
onEnter() {
// run some code
}

onLeaveCompleted() {}
}

Here's the code plus styling for this little demo site. Feel free to check out my starter template where I use Eleventy and Highway as well.