The Astro THREEjs starter project that nobody asked for!
If you have seen the demos of Astro websites using native browser page transitions, and thought I want them with my THREE.js, you are not alone.
I get it, I want them too.
So how to get them to play nicely with three.js in Astro?
Load the memes and let’s go work it out.
Let’s start with installation of Astro, the docs are here.
Let’s install astro using the cli.
pnpm create astro@latest
Follow the cli prompts start and Empty project.
The first things thing we will do, is setup two pages for the index and about page, and add transitions between the two pages.
src/
├── pages/
│ ├── index.astro
│ └── about.astro
We will add two components to reduce some duplication on both our index and about page.
---
import { ViewTransitions } from 'astro:transitions';
export interface Props {
pageTitle: string;
}
const { pageTitle } = Astro.props;
const BASE_URL = import.meta.env.BASE_URL
---
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="generator" content={Astro.generator} />
<link rel="icon" type="image/svg+xml" href={`${BASE_URL}/favicon.svg`} />
<link rel="stylesheet" href={`${BASE_URL}/style.css`}>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@100..900&family=Pixelify+Sans:wght@400..700&display=swap" rel="stylesheet">
<ViewTransitions />
<title>{pageTitle}</title>
</head>
Take note of the ViewTransitions import. This is the Astro magic that will automatically handle page transitions when switching between our pages.
Now let’s create a component for out menu, in order to navigate between pages.
---
const BASE_URL = import.meta.env.BASE_URL
---
<ul class="page-menu">
<li>
<a href={`${BASE_URL}/`} class={Astro.url.pathname === `${BASE_URL}` ? "active" : ""}>
home
</a>
</li>
<li>
<a href={`${BASE_URL}/about/`} class={Astro.url.pathname === `${BASE_URL}/about/` ? "active" : ""}>
about
</a>
</li>
</ul>
Now let’s use these components <Head />
and <Menu />
in both out pages.
---
import Menu from '../components/menu.astro';
import Head from '../components/head.astro';
---
<html lang="en">
<Head pageTitle="Astro: home page" />
<body>
<div class="canvas-wrapper">
<canvas id="three-js-canvas" />
</div>
<div class="page-wrapper">
<h1 class="page-heading">Astro demo with Three.js and page transitions</h1>
<Menu />
<div class="page-box">
{[...Array(50)].map(() => (
<p>home page home page home page home page home page home page home page home page home page home page home page home page</p>
))}
</div>
</div>
</body>
</html>
---
import Menu from '../components/menu.astro';
import Head from '../components/head.astro';
---
<html lang="en">
<Head pageTitle="Astro: about page" />
<body>
<div class="canvas-wrapper">
<canvas id="three-js-canvas" />
</div>
<div class="page-wrapper">
<h1 class="page-heading">Astro demo with Three.js and page transitions</h1>
<Menu />
<div class="page-box">
{[...Array(50)].map(() => (
<p>about page about page about page about page about page about page about page about page about page about page about page about page</p>
))}
</div>
</div>
</body>
</html>
Ok, at this point we have a markup for our pages, now let’s add some css to make this look halfway decent.
This css file will be referenced in the <Head />
component
<link rel="stylesheet" href={`${BASE_URL}/style.css`}>
body {
margin: 0;
background: #1f1f1f;
color: #fff;
box-sizing: border-box;
font-family: "Pixelify Sans", -apple-system, BlinkMacSystemFont, "Segoe UI",
Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji",
"Segoe UI Symbol";
font-optical-sizing: auto;
font-weight: 400;
font-style: normal;
overflow-x: hidden;
background-image: radial-gradient(circle, #535353 1px, transparent 1px);
background-size: 15px 15px;
}
* {
box-sizing: border-box;
}
a {
color: #fff;
}
#three-js-canvas {
position: relative;
}
.canvas-wrapper {
position: absolute;
overflow: hidden;
width: 100vw;
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.page-wrapper {
position: relative;
padding: 40px;
}
.page-heading {
text-align: center;
}
.page-menu {
display: flex;
justify-content: center;
width: 100%;
list-style: none;
text-transform: uppercase;
gap: 20px;
font-size: 24px;
}
.page-menu a {
text-decoration: none;
}
.page-menu a.active,
.page-menu a:hover {
text-decoration: underline;
}
.page-box {
width: 100%;
height: calc(100vh - 250px);
background: rgba(0, 0, 0, 0.5);
padding: 20px;
overflow: hidden;
text-align: center;
}
.page-box p {
font-size: 34px;
line-height: 34px;
}
Now let’s add Astro page transitions in all their glory.
Astro view transitions give us a simple fade as a default, the transitions can be customized like so:
---
import { fade } from 'astro:transitions';
---
<header transition:animate={fade({ duration: '0.4s' })}>
3D time! With the two astro pages setup with transitions, let’s add THREE.js to both pages.
First step is to install THREE.js on the command line.
pnpm add three
Once installed let’s create two files entry.ts
and create-scene.ts
src/
├── scripts/
│ ├── create-scene.ts
│ └── entry.ts
In the file create.scene.ts
we are going to create a THREE.js scene of a rotating animating cube. This tutorial is more focused on the integration of Astro and THREE.js rather than THREE.js.
If you interested in getting started in the world of THREE.js I recommend the course threejs-journey.
import * as THREE from "three";
type SceneState = {
scene: THREE.Scene;
camera: THREE.PerspectiveCamera;
renderer: THREE.WebGLRenderer | null;
cube: THREE.Mesh | null;
animationId: number | null;
};
type SavedState = {
rotation?: {
x: number;
y: number;
z: number;
};
position?: {
x: number;
y: number;
z: number;
};
};
const createScene = (canvasId: string) => {
const state: SceneState = {
scene: new THREE.Scene(),
camera: new THREE.PerspectiveCamera(75, 1, 0.1, 1000),
renderer: null,
cube: null,
animationId: null,
};
const loadSavedState = (): SavedState | null => {
try {
const savedState = localStorage.getItem(`cube-state-${canvasId}`);
if (savedState) {
return JSON.parse(savedState);
}
} catch (error) {
console.warn("Error loading saved state:", error);
}
return null;
};
const saveState = (): void => {
if (!state.cube) return;
const stateToSave: SavedState = {
rotation: {
x: state.cube.rotation.x,
y: state.cube.rotation.y,
z: state.cube.rotation.z,
},
position: {
x: state.cube.position.x,
y: state.cube.position.y,
z: state.cube.position.z,
},
};
try {
localStorage.setItem(`cube-state-${canvasId}`, JSON.stringify(stateToSave));
} catch (error) {
console.warn("Error saving state:", error);
}
};
const initRenderer = (): THREE.WebGLRenderer | null => {
const canvas = document.getElementById(canvasId) as HTMLCanvasElement;
if (!canvas) return null;
const renderer = new THREE.WebGLRenderer({
canvas,
antialias: true,
alpha: true,
});
renderer.setSize(800, 800);
renderer.setClearColor(0x000000, 0);
return renderer;
};
const createCube = (savedState: SavedState | null): THREE.Mesh => {
const geometry = new THREE.BoxGeometry(1, 1, 1);
const material = new THREE.MeshPhongMaterial({
color: 0xf2f2f2,
flatShading: true,
transparent: true,
opacity: 1,
});
const cube = new THREE.Mesh(geometry, material);
if (savedState) {
if (savedState.rotation) {
cube.rotation.x = savedState.rotation.x;
cube.rotation.y = savedState.rotation.y;
cube.rotation.z = savedState.rotation.z;
}
if (savedState.position) {
cube.position.x = savedState.position.x;
cube.position.y = savedState.position.y;
cube.position.z = savedState.position.z;
}
} else {
cube.position.z = -2;
}
return cube;
};
const setupLights = (): THREE.Light[] => {
const light1 = new THREE.DirectionalLight(0xffffff, 1);
light1.position.set(1, 1, 1);
const light2 = new THREE.AmbientLight(0x404040);
return [light1, light2];
};
const animate = (): void => {
if (!state.cube || !state.renderer) return;
state.cube.rotation.x += 0.01;
state.cube.rotation.y += 0.01;
state.renderer.render(state.scene, state.camera);
state.animationId = requestAnimationFrame(animate);
saveState();
};
const init = (): void => {
state.renderer = initRenderer();
if (!state.renderer) return;
const savedState = loadSavedState();
state.cube = createCube(savedState);
state.camera.position.z = 0;
state.scene.add(state.cube);
const lights = setupLights();
lights.forEach((light) => state.scene.add(light));
animate();
};
const cleanup = (): void => {
saveState();
if (state.animationId) {
cancelAnimationFrame(state.animationId);
}
if (state.cube) {
state.cube.geometry.dispose();
}
if (state.renderer) {
state.renderer.dispose();
}
};
return {
init,
cleanup,
};
};
export default createScene;
Now we will reference this file from our entry.ts
file.
import createScene from "./create-scene";
type SceneManager = {
init: () => void;
cleanup: () => void;
};
const CANVAS_ID = "three-js-canvas";
const sceneManager: Record<string, SceneManager> = {};
document.addEventListener("astro:before-swap", () => {
Object.values(sceneManager).forEach((scene) => scene.cleanup());
});
document.addEventListener("astro:page-load", () => {
sceneManager[CANVAS_ID] = createScene(CANVAS_ID);
sceneManager[CANVAS_ID].init();
});
The important events here are both astro:before-swap
and astro:page-load
. The astro:before-swap
and astro:page-load
events are crucial for managing the lifecycle of the Three.js scene.
On the astro:before-swap
we clean up the existing Three.js scene, ensuring that any previously rendered resources (e.g., memory-intensive objects or animations) are properly disposed of.
This event fires after Astro completes loading the HTML content for a new page. You use it here to initialize your Three.js scene once the new page has loaded.
As you can see below the THREE.js example works nicely with the page transitions.
To see the demo click here.
To view the code on Github.