The Descent: A Replit Site Where Scrolling Falls Through Seven Worlds Hidden Inside a Book
Overview
The Descent opens on an old title page: The Book Without End, with a tale within a tale underneath, and the lettering soaks in like ink. When you scroll, you don't move down the page. You move into it. A bookshop at night has an open book on a reading stand. Inside the book is a ship on a moonlit sea. Through the ship's porthole is the captain's cabin, and on the chart table is a snow globe with a village inside it. It goes on through an attic, an observatory and out to a single star, and then the star turns out to contain the bookshop you started in.
Try it: inkwell-press-the-descent.replit.app. Scroll slowly, and scroll back up to reverse the journey.
I built it with Replit Agent as a landing page for Inkwell Press, a made-up publisher of illustrated fiction. It's a piece of visual storytelling, not a working shop: there's no catalogue, checkout or backend. This post covers how it works, taken from the shipped code rather than the agent's summary, what I changed along the way, and what still isn't right.
What it is: 15 illustrated image layers, each one sitting in the exact centre of the one before it at half the size, composited by a hand-written WebGL 1 shader. Scroll position sets the zoom. Seven of the layers are named worlds where the scroll slows down and a line of the story fades in.
Stack: Vite, plain TypeScript, raw WebGL 1 with a Canvas 2D fallback. No Three.js and no animation library. The whole script is about 19 KB. Type is IM Fell English and IM Fell English SC. Images are WebP: 3072×1728 on desktop (5.3 MB for all 15), 1536×864 on phones (1.7 MB).
The seven worlds
Each named world has an "establishing" image, where you arrive, and a "portal" image, which zooms into the object that leads to the next world. Seven worlds use 14 images. The 15th is a loop image that takes you from inside the star back to the shop.
| World | What you see | The way in | Story line |
|---|---|---|---|
| The Shop | A small bookshop at night, rain on the windows | An antique book open on its stand | (the title page) |
| The Sea | A three-masted ship on a moonlit sea | One glowing porthole | "Beyond the page, the sea lay waiting." |
| The Cabin | A captain's cabin by candlelight | A snow globe on the chart table | "In the captain's keeping, a winter slept." |
| The Village | A snowy mountain village at blue hour | One warm cottage window | "One window kept the night at bay." |
| The Attic | A writer's attic study at night | A gilded painting on the wall | "Within, a world hung upon the wall." |
| The Observatory | An old domed observatory | A brass telescope eyepiece | "And there, a glass was turned toward heaven." |
| The Star | An ink-blue, teal and amber nebula | One white-gold star | "Even the stars had a story within." |
Every story line describes the portal, not the world. That's the point of them: each line tells you where you're about to go next.
How the zoom works
The whole effect rests on one rule, which the shader's opening comment states: every image is the level nested inside the previous one, filling exactly its centre half. If the shop is 100% wide, the book's page is exactly 50% wide in the middle of it. The sea image is drawn into that 50%, the porthole takes the middle 25%, and so on.
Because of that, the zoom is one number. Scroll position is converted
to a depth z between 0 and 15. The integer part picks
which image is the outermost layer, and the fractional part sets its
scale to 2fract(z), a value between 1 and 2.
When it reaches 2, the next image fills the screen at exactly scale 1,
so the shader moves one level down and starts again. Nothing jumps,
because both frames look identical.
On each frame the shader samples up to four images at once: the
current one at q, the next at q × 2, then
q × 4 and q × 8. Each inner image is blended
over the outer one with a soft rectangular mask (a 3% feather at the
edges), which is what hides the border between them. Four layers is
enough because anything deeper is too small to see.
Scrolling slows down at each world
If scroll mapped straight to depth, you would fly past every world at the same speed. Instead, the zoom speed drops to a quarter within a quarter level of each named world, then smoothly returns to full speed by half a level away. The code builds a 6,000-entry lookup table of "scroll needed per bit of depth" when the page loads, and finds your depth each frame with a binary search.
I measured the result from the shipped numbers: the areas near the seven worlds are 23% of the depth, but they take up 48% of the scroll. So you spend about half your time looking at the worlds and only half travelling between them. That's the most important piece of pacing in the site.
The page is 3.5 screen heights of scroll per level on desktop (52.5 screens in total) and 2 per level on phones. The actual scroll position is smoothed, moving 10% closer to the target each frame, so a hard flick of the trackpad still reads as a smooth descent.
The loop
When you reach the bottom of the page, the script jumps you back to the
top. Because the 15th image is the loop image, which leads back into
the shop, you just keep going and don't notice the jump. The title page
only shows on the first pass; after one loop it stays hidden. Adding
?loop=0 to the URL turns looping off.
What makes it feel physical
With nothing but a zoom it looks like a slideshow. These smaller effects, all in the one fragment shader, are what make it look like travelling:
- Zoom blur that depends on speed. The shader averages up to 8 samples along a line toward the centre. The blur strength comes from how fast you're scrolling, so it's sharp when you stop and streaks when you go fast. When you're still it takes just one sample; on phones it takes at most 4.
- Colour fringing at the edges (chromatic aberration), also based on speed, and turned off on phones.
- A slight spiral. The whole image rotates by up to about 1.4° over one full loop, following a sine wave, so the descent feels like it's turning very slowly.
- Drift. Moving the pointer shifts the view up to 1.2% of the frame, eased so it lags behind your hand. On phones, touch position does the same, and on Android the device's tilt sensor can drive it too. iOS needs a permission prompt for the tilt sensor, so the code doesn't use it there.
- Painted "life". A small amount of animated noise moves the texture coordinates, so the paintings shimmer slightly. It's held still in the middle of the frame, so the part you're heading toward stays sharp.
- Grain and vignette: film grain at 7% (stronger in dark areas) and a 40% vignette. A 3% overscan keeps the drift from showing the edge of the image.
Loading 15 large images without stalling
The desktop images add up to 5.3 MB. The loader never tries to fetch all of them. It keeps six GPU texture slots and a "wanted" list of the current level, the next three, and one more in whichever direction you're scrolling. Other details from the loader:
- At most three images download at once, sorted by how soon you'll need them.
- Only one texture is uploaded to the GPU per frame, so uploads don't cause visible stutter.
- A failed image gets three attempts in total, with waits of 0.4 and then 1.6 seconds between them.
- Decoded images more than six levels away are dropped from memory.
- Until the first four levels are ready, you see a static poster JPEG of the shop. The WebGL canvas fades in over it after that, so the first screen never looks broken.
- Phones (up to 820px wide) get the 1536px images. The pixel ratio is capped at 2 everywhere (1.5 in the Canvas 2D fallback).
The renderer also handles webglcontextlost. If the
browser takes the GPU context away, which happens on phones more than
you'd expect, it rebuilds the program and uploads the textures again
rather than leaving a black screen. If WebGL isn't available at all,
a Canvas 2D renderer takes over. It draws the same four nested images
with drawImage and a gradient vignette, without the blur,
grain or shimmer.
What I changed along the way
A title page instead of a landing page
The first version was a normal publisher homepage laid over the zoom: a header, navigation, buttons, a progress display, and a scroll prompt, with lines like "Small print runs. Big worlds." at each stop. I had all of it removed. The images carry the whole thing, and every piece of UI made it feel more like a website and less like a book.
What replaced it is a title page. The imprint, the title, a thin gold rule and the subtitle each reveal with a CSS mask: a gradient that goes from solid to transparent is slid across the text, so it looks like ink spreading from left to right. They're staggered over about six seconds. The type is IM Fell English, a digitised 17th-century typeface, with the small-caps version for the imprint and the large initial "T".
That "T" was being clipped. It's set 1.32× larger than the rest of
the title with line-height: 0, so it sticks out past the
title's box. The fix was padding around the title
(.3em .12em .12em) so the letter has room.
Bringing the story back
Removing the promotional copy also removed the only text in the later worlds, which left them feeling empty. So I asked for the story to come back as one line per world, in the bottom-right corner. The lines use the same italic type and the same ink reveal as the title, with no panel or background behind them, just a soft text shadow so they're readable on bright images.
Their timing comes from depth, not from time. A line starts to appear 0.62 levels before its world, is fully shown by 0.2 before, and fades out between 0.22 and 0.55 after. It also rises 6px as it appears. If you stop scrolling halfway through a line's reveal, it stays half-revealed, which makes it feel like part of the scroll rather than an animation playing over it.
Reduced motion and no JavaScript
A tiny inline script in the <head> adds a
zoom class to the page unless
prefers-reduced-motion is on. Every part of the zoom
layout depends on that class. Without it, either because you asked for
less motion or because JavaScript didn't run, you get the same seven
worlds as a normal page: a heading, the image and the story line for
each. The same markup is also what screen readers get in the zoom
version, where it's visually hidden, so the world names, image
descriptions and story text are always available.
What still isn't right
I'd rather say this than have you find it:
- Some transitions show seams. The feathered mask hides the edge of each image, but where the painted texture doesn't match on both sides of the border, you can see it as you pass through.
- Round portals stretch. The porthole, the snow globe and the telescope eyepiece are circles, but each inner image is a 16:9 rectangle. Around a few of them the zoom visibly stretches. The fix would be to paint the images so each portal really fills its centre rectangle, not a code change.
- Leftover copy. The page's meta description still says Inkwell publishes "Twelve books a year", and the old headlines are still in the JavaScript bundle, though they're never displayed.
- Not every caption has been checked by eye. The build passed its checks and the preview renders, but I haven't scrolled through every world on every device to watch each new line appear.
A prompt to build your own
I didn't keep this as one prompt; the site came together over several rounds. This is a single prompt I wrote afterwards from the shipped code, so you can get the same structure in one go. Bring your own images, or ask the agent to generate them, and read the note after it first.
The images are the hard part, and the prompt can't fix that for you. The code will composite whatever you give it, but if image N+1 doesn't really match the centre of image N, you'll see a seam on every transition. Test the chain at small size first by placing each image at half scale in the centre of the one before it, and check the borders before you build anything.
Recap
-
The Descent is 15 images, each nested in the centre half of the
previous one, so the entire zoom is a single number:
2fract(z). - Scrolling slows to a quarter speed near each of the seven worlds, so half your scroll is spent looking at them rather than travelling between them.
- Speed-based blur, a slight spiral, pointer drift, shimmer and grain make the zoom feel like movement. A windowed loader keeps only six images on the GPU at once.
- I removed the landing-page UI, added an ink-reveal title page, and brought back one story line per world. Reduced motion and no-JS both get a static version.
- Seams and stretched round portals are still visible in places. Those need better images, not more code.
- Try it at inkwell-press-the-descent.replit.app.
More scroll-driven Replit builds: a scroll-scrubbed cinematic WebGL site, a premium property site with scroll choreography, and Cosmo, a 3D robot companion you can boop and throw.