Get started with Astro, THREEjs and view transitions

The Astro THREEjs starter project that nobody asked for!

Cage McCage let's go

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.

Cage McCage let's go

Installing Astro

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

Astro components

We will add two components to reduce some duplication on both our index and about page.

head.astro
---
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.

menu.astro
---
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>

Astro pages

Now let’s use these components <Head /> and <Menu /> in both out pages.

index.astro
---
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>
about.astro
---
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>
 

Styles

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`}>
Cage McCage let's go
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;
}

Page Transitions

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' })}>

Three.JS

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.

create-scene.ts
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.

entry.ts
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();
});

astro:before-swap

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.

astro:page-load

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.

Share:
Made with by ZG codes