Putting a Three.js island inside a static site

The isle page downloads only its own script. The scene is computed at runtime, Three.js stays in that page's chunk, and the first HTML response carries an empty stage.

The short version: a 3D scene belongs on a static site as long as it is loaded per page, not shared site-wide. The isle page ships zero lines of Three.js in its HTML — only an empty stage of a fixed height. The browser parses the document, and only then starts downloading the scene chunk.

An empty stage, and everything under it

The skeleton has three layers: an outer stage with an ink border and a hard offset shadow, an absolutely positioned mount point inside it, and three states — loading, ready, failed. The states are switched with a data-state attribute, and the skeleton, the error text and the retry button are in the HTML from the start rather than being generated by script.

That buys two things. First, no first-paint jump: the stage height is fixed by clamp(), so the page has its final height before the script arrives. Second, failures are explainable: on a browser without WebGL the reader gets one honest sentence and a retry button instead of a blank rectangle.

Mounting follows the same pattern as the games and tools on this site — a static shell, a dynamic import(), and createApp:

const loaders = import.meta.glob<{ default: Parameters<typeof createApp>[0] }>('./*/Scene.vue');

const loader = loaders['./' + slug + '/Scene.vue'];
if (!loader) throw new Error('isle component not found');
const mod = await loader();
createApp(mod.default, { locale }).mount(root);

import.meta.glob is not decoration here. Vite expands it at build time into a mapping of separate chunks, so Three.js, five post-processing modules and the whole scene are downloaded only by people who opened this page. None of the other 400-odd pages in the output carry them.

Everything is generated at runtime

There is not a single texture or model file on the island. The rock is a hand-triangulated stack of irregular rings, shaded per vertex; the water and the waterfall are two custom ShaderMaterials driven by a time and a night uniform; grass, flowers and moss run through InstancedMesh at around 1,200 instances; even the soft glow sprite is a radial gradient drawn into a canvas in memory.

The price is a heavier first frame, so the main island’s shadow map is computed once and then frozen:

renderer.shadowMap.autoUpdate = false;
renderer.shadowMap.needsUpdate = true;

Nothing that moves — satellites, fireflies, petals, clouds — casts a shadow. One frame is roughly three hundred draw calls and fifty thousand triangles, which a laptop and a recent phone can both hold.

Aligning it with the Riso language

The original was a standalone dark page, and dropping it in as-is would fight the paper-and-ink palette. So only the scene came over; the viewing console was redrawn — right angles, monospace uppercase labels, zero-blur hard shadows, all reading --c-* tokens. Dark mode follows the site theme and is a different thing from the scene’s own twilight/night switch: one is the colour of the paper, the other is the weather on the island.

The small line in the top-left corner is measured, not decorative: draw calls, triangles and readiness, polled from the renderer every 900 ms. It is both a debug readout and this page’s spec sheet.

What it costs

Let us be honest about the cost: Three.js takes this page from a few dozen kilobytes of script to roughly six hundred (about 150 KB gzipped). Only the people who open the isle page pay it — but they do pay it.

Which is why this page should not go in the homepage hero, and should not be embedded as an illustration inside an article. It is a destination, not a place you pass through.

Closing

Static hosting and 3D are not in conflict. Confine the heavy thing to its own route, let HTML rather than script own the three states, and what remains is a design problem.

← Back to all posts

Comments

…