The Descent: A Replit Site Where Scrolling Falls Through Seven Worlds Hidden Inside a Book

Published September 2026 · avivashishta.com

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.

WorldWhat you seeThe way inStory 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:

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:

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:

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.

Build an "infinite zoom" storytelling landing page: scrolling travels inward through a chain of illustrated worlds nested inside each other, and loops back to the start. Images - 15 images at 16:9 (3072×1728 for desktop, 1536×864 for screens ≤820px wide), WebP. - The rule everything depends on: each image must be exactly what you'd see inside the centre half of the previous image. Image N+1 fills the middle 50% of image N. - 7 named worlds, 2 images each: an establishing shot, then a "portal" shot that zooms toward the object leading to the next world. The 15th image leads from the last world back into the first, so the loop is seamless. Renderer - Hand-written WebGL 1, one full-screen triangle pair, one fragment shader. No Three.js. - Scroll position maps to a depth z in [0, 15). floor(z) picks the outer image; scale it by 2^fract(z). Composite it with the next 3 images at q×2, q×4, q×8, each blended in with a feathered rectangular mask (~3% feather). - Cover-fit the 16:9 plate to any viewport, with 3% overscan. - Radial zoom blur toward the centre (up to 8 taps, 4 on mobile, 1 when still), strength based on scroll speed. Speed-based chromatic aberration at the edges, desktop only. - Rotate the whole stack up to ~1.4° over one loop as a sine wave. - Pointer moves the view up to 1.2% of the frame, eased. Use touch position on mobile. - Subtle animated noise on the texture coordinates, held still in the centre of the frame. Film grain ~7%, vignette ~40%. - Canvas 2D fallback if WebGL is unavailable. Handle webglcontextlost / restored. Pacing - Page height: 3.5 viewport heights per level on desktop, 2 on mobile, with a sticky full-screen stage. - Slow the zoom to 1/4 speed within 0.25 levels of each named world, smoothly back to full speed by 0.5 levels. Precompute a lookup table and invert it with a binary search. - Smooth the scroll position (move 10% toward the target per frame). Scrolling up reverses. At the bottom, jump to the top without a visible change; ?loop=0 disables looping. Loading - Keep 6 GPU texture slots. Wanted list: current level, the next 3, and one more in the scroll direction. Max 3 downloads at once, 1 GPU upload per frame, 3 attempts per image with backoff, drop decoded images more than 6 levels away. - Show a static poster JPEG until the first 4 levels are on the GPU, then fade the canvas in. Type and story - Opening title page in IM Fell English (small caps for the imprint and a large initial letter): imprint, title, thin gold rule, italic subtitle. Reveal each with a sliding gradient mask so it looks like ink soaking in. Give the large initial enough padding that it isn't clipped. Fade the title out over the first half-level, and don't show it again after a loop. - One italic story line per world after the first, bottom-right, no panel, soft text shadow. Tie it to depth, not time: reveal from 0.62 to 0.2 levels before the world, fade out 0.22 to 0.55 after, same ink mask, rising 6px. - No header, navigation, buttons, progress bar or scroll prompt. Accessibility - Only enable the zoom layout when JavaScript runs and prefers-reduced-motion is off (add a class from an inline head script). Otherwise show the worlds as a normal page: heading, image with alt text, and story line for each. Keep that markup for screen readers in the zoom version.

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

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.

← Portfolio · Blog