Web Graphics
How the Sky Islands at the Top of This Site Work
A painted world in Canvas 2D with no images or libraries: layers, two web workers, and the rules that keep it fast and easy to play with.
- Canvas
- Web Workers
- Performance
The top of this site is a small world: a floating island with a lantern tree, a sea of clouds, whales, a mill, and a field guide that fills in as you look around. I wanted a home page that shows how I build things instead of only describing it. It also had to load like a normal page, which turned out to be the harder part.
No images, one seed
There are no image files in it. Every rock, leaf and cloud is drawn with the Canvas 2D API when the page loads. The randomness comes from a seeded generator, so it is the same island on every visit.
That kept the download small. The whole engine, with every scene and creature, is about 110 KB of gzipped JavaScript and uses no libraries. A painted background image at full resolution would usually weigh more than that, and it couldn't move.
Paint once, draw what moves
Most of the scene never changes: the sky, the far mountains, the ridges, the island's rock. Redrawing those every frame would waste nearly all the work. So the world is split into 11 stacked canvases. Eight are painted once for each size and theme, and after that they only slide. The other three hold everything that moves, and only those are redrawn each frame.
export const LAYERS = [
{ d: 0, res: 0.6, paint: "sky" },
{ d: D_FAR, res: 0.5, paint: "far" },
{ d: D_BACK, res: 0.5, paint: "ridge" },
{ d: D_BACK, res: 0.8, draw: "back", half: "odd" },
{ d: 0.3, res: 0.5, paint: "mid" },
{ d: 0.4, res: 0.5, paint: "flanks" },
{ d: D_ISLE, res: 1, paint: "island" },
{ d: D_ISLE, res: 1, draw: "isle" },
{ d: 0.65, res: 0.45, paint: "front" },
{ d: D_FRONT, res: 1, half: "even", draw: "front" },
{ d: 1, res: 0.3, paint: "fg" },
];
Each layer has a depth, d, which sets how far it slides with the pointer and how much it lags behind the page as you scroll. That alone gives the shadow-box look. Each layer also has its own resolution. The sky is a soft gradient, so it renders at 60% of full resolution. The dark ferns in the foreground are blurred anyway, so 30% is enough. The island, which is what you look at, gets all of it. A layer that only covers a band of the scene, like the wooded ridge, gets a canvas only that tall, so the browser has less to composite.
Here it is running, on the same renderer as the top of the home page. Take it apart to see the canvases: the three edged in gold are drawn every frame, and the rest were painted once. Pick a name to see that canvas alone.
Getting it off the main thread
The static layers were always painted in a web worker, which sends them back as ImageBitmaps that move between threads without being copied. But the first version drew every frame on the page's main thread. Desktop Lighthouse scored it 97. Mobile Lighthouse, which slows the CPU down four times, scored it 61. The cost was the drawing, not the download.
A second worker fixed that. The render worker owns the canvases themselves: the page calls transferControlToOffscreen() on each one and hands it over, and from then on the worker draws every frame. The main thread reads the pointer and the scroll position, posts them across, and moves the finished layers with CSS transforms.
Browsers without OffscreenCanvas run the same renderer on the main thread. It sends and receives the same messages either way, so there is one renderer to test, and if the worker fails to start, the page falls back to it on fresh canvases.
After the move, mobile Lighthouse scores between 94 and 97 from run to run, desktop scores 99, and nothing shifts while the page loads. The largest contentful paint is my name in the header, not the world.
The theme switch
The world has two palettes: golden hour for the light theme and night for the dark one. My first theme switch repainted the static layers on the spot, which froze the page for about a second. Now the paint worker paints the other theme in the background as soon as the first one is up, so switching only swaps bitmaps. The change spreads out from the sun or the moon, using the View Transitions API with a soft circular mask.
When a device can't keep up
The renderer keeps a running average of how long frames take. If they run long for a while, it switches to a lite mode for the rest of the visit: the animated layers drop to a pixel ratio of 1, and the two busiest ones redraw on alternate frames, so no single frame carries both. If frames still run long after that, the animated layers drop again, to 70% of that resolution. Firefox on Linux needs this second step, because it draws canvases on the CPU, where the cost follows the number of pixels. It never switches back, because flipping in and out would be more noticeable than either mode.
If your system asks for reduced motion, the world draws once and holds still.
Rules for touching it
I wanted it to be something you play with, without any instructions on screen. A few rules made that work:
- Hovering only looks. It names a thing in the field guide and may change it in place, but nothing moves out from under the cursor.
- A click touches. It pops a bubble, pushes the swing or startles a fish in the clouds.
- Not everything arrives at once. On a first visit the world opens with only places and weather, and the creatures arrive one at a time over about a minute.
The hit tests behind the hover labels run in the render worker too, against the same shapes it draws, and the answer comes back with each frame.
The world also follows your calendar. The lantern tree is a maple: through autumn its leaves turn red and orange, a few more every day from the start of September, and blow off it. They pile up round its trunk, and a quick sweep of the pointer through the pile sends them flying with a soft crunch. Click a leaf as it falls and you catch it: it is pressed in the field guide, and tucked into the corner of that autumn's postcards. In spring its seeds spin down like little helicopters, and a click sends one whirling back up. In winter snow falls and slowly settles on the tree while you watch. On a few days it dresses up. In Halloween week the lanterns are pumpkins and some of the starlings are bats, and at New Year a click on the sky sets off a firework. The case studies have small painted scenes made with the same code, and the field guide can save a postcard of the view, postmarked with the date and season.
The source is in lib/world (opens in a new tab) if you want to read it.