Overview
One page. One <section> sixty viewports tall with a
single sticky child that never leaves the screen. As you scroll, an
oil-painting world plays forwards and backwards under your finger —
figures in linen and crimson, marble plinths, scaffolding around a
colossal golden object — and dissolves between scenes through halftone
dots, radial rings and noise-warped wipes. Hairline rules draw in.
Headlines fade up in a serif with optical sizes. Film grain sits on top
of everything, and highlights pull toward cream.
It looks like video. It is not. It is around six hundred WebP stills, indexed by scroll position, uploaded two at a time into a WebGL shader that cross-dissolves them. The whole thing is vanilla TypeScript and hand-written WebGL1 with no animation library at all, and the JavaScript comes in under 350 KB.
The reference is the class of high-end WebGL agency sites that pear.no is the cleanest example of. This post is the build brief for that class of site, turned into a single prompt for Replit Agent — the architecture, the frame loader, the shader, the asset pipeline, the scroll timeline, and the fallbacks — with the reasons each clause exists afterwards.
What it produces: a sticky stage driven by one
normalized progress value, five to seven scroll-scrubbed WebP frame
sequences plus one 360-frame centrepiece reel, a progressive frame
loader with a halving-stride load order and a priority window around
the current frame, a two-texture WebGL1 renderer with six named
transitions and an always-on grain-and-grade pass, a 2D hairline
overlay, real HTML text layers, two ambient
<video> loops, an ffmpeg script that decomposes
generated clips into budgeted sequences, and a reduced-motion and
WebGL-lost fallback for each.
Stack: vanilla TypeScript on Vite, raw WebGL1, native
scroll, one requestAnimationFrame loop. No GSAP, no
ScrollTrigger, no Three.js, no Lenis, no Framer Motion.
Generate the motion first
On the property site the assets were photography you had to source before the agent wired anything. Here the assets are the engine's fuel, and there is a specific order of operations that works and a tempting one that does not.
- Do not ask an image model for 121 consistent frames. They will not match. Faces drift, the horizon moves, the palette shifts a few degrees per frame, and the scrub reads as a flip-book of near-misses. Generate one still per scene, then hand that still to an image-to-video model for motion, then decompose the clip. Consistency comes from the video model for free.
- One shared style suffix on every still prompt. The art direction, "oil painting realism, soft directional sunlight, fine canvas grain, muted warm palette", appended verbatim to all six prompts. Six scenes that were prompted separately without it look like six different sites.
- Slow, single-axis camera moves only. "Slow push in", "slow crane up", "wind in fabric, camera locked". Dramatic motion scrubs badly, because a big change between adjacent frames strobes, and it compresses worse, because WebP has more to encode. Boring clips make better sequences.
- Budget in numbers before you encode. 60–120 KB per desktop frame, 25–35 KB per mobile frame, under 12 MB per desktop sequence. Print the directory size after every run. Without a number the agent will ship quality 90 and a 40 MB folder, and you will not notice until the sequence stalls on a real connection.
The prompt
Paste the whole thing at once. Fill in the BRAND BRIEF
block first.
Why the prompt is written this way
The first version of this brief was a paragraph: a cinematic single-page
site, WebGL, video that plays as you scroll, grain, a serif. What came
back was a <video> element with
currentTime set from a scroll listener, a Three.js
PlaneGeometry with the video as a texture, and a GSAP
timeline to fade the headlines. It worked in the sense that scrolling
did something. It also stuttered on every seek, hitched for a full
second whenever the browser hit a keyframe boundary, shipped 1.4 MB of
JavaScript before a pixel was drawn, and showed black between sections
while the next clip loaded.
None of that is the agent's fault. It is what "scroll-driven video" means on the internet. Every clause below is in the prompt because its absence produced one of those failures, repeatably.
Ban video scrubbing by name, and say what to do instead
Seeking a <video> element by scroll position is
janky on every browser. Instead: frame sequences.
This is the clause I would keep if I could keep only one. Video decoders are built to play forwards at a fixed rate. Ask one to jump to an arbitrary timestamp and it has to find the previous keyframe, decode every frame between there and where you want, and then present — and do it again on the next scroll tick, possibly backwards. Chrome does this with a visible hitch. Safari does it with a hitch and a few frames of the wrong image. Nothing you can do in JavaScript changes the fact that the format is not random-access.
A folder of stills is random-access. Frame 87 costs the same
to show as frame 3. So the prompt does not describe the effect and hope
the agent finds a way; it names the structure — numbered WebP files, a
manifest, a mobile tier — and bans the alternative in the same
sentence. Banning by name matters here for the same reason it did with
pin: true on the property site: "prefer image sequences"
gets you sequences for the first section and a video with
currentTime for the two the agent writes after it.
Then the prompt gives video back, narrowly: two ambient loops that play forwards on their own, with the exact attribute list that makes iOS autoplay actually work. That is what video decoders are for, and allowing it there stops the agent from trying to fake a loop out of stills.
One progress number, one loop
Read scrollY once per frame, lerp p, drive
everything from that one number. No per-element scroll listeners.
Left to itself an agent will register a scroll listener per section, an IntersectionObserver per text block, and a resize handler that recomputes all of them, and then wonder why the frame index and the headline opacity are half a frame out of sync. Reading scroll position in a listener also means reading it whenever the browser feels like firing the event, which on a trackpad is many times per frame and on a wheel is in bursts.
One requestAnimationFrame loop that reads
scrollY once, derives a single 0..1, and
pushes it into everything is not just cleaner. It is the only way the
frame you see, the grain on it, the ring wipe halfway through it, and
the headline over it are all computed from the same scroll position. The
lerp gives you inertia without a smooth-scroll library, and it also
means the sequence never asks for a frame more than a few indices from
the last one, which is what lets the loader's priority window work.
Specify the load order, not just "lazy load"
Build the order with a halving stride so a coarse pass across the whole sequence lands first, then refines.
"Lazy load the frames" gets you frames loaded 1 to N in order. The user scrolls to 60% of the sequence in the first second and looks at black, or at frame 12 frozen, while frames 13 through 72 arrive one at a time. Loading every 32nd frame first, then every 16th, then every 8th means that after the first dozen requests the entire sequence is watchable at low temporal resolution, and every subsequent request makes it smoother. It is progressive JPEG for time.
The priority window is the other half. The halving stride is the right order when the user is not scrolling. When they are, the frame they need now matters more than the schedule, so the loader checks ±24 around the wanted frame first and only falls back to the stride when that window is full. Neither idea alone is enough; the prompt spells out both and the eleven-line snippet, because "progressive loading" is another adjective.
Never blank
A nearest(i) accessor returns the closest frame that is
actually decoded. Draw that.
The single biggest visible difference between an amateur frame scrub and a professional one is what happens when the frame you want has not arrived. The default is nothing — the texture is empty and you see the clear colour, which is a black flash. Drawing the nearest decoded neighbour instead means the worst case is a frame that is slightly wrong for a few hundred milliseconds. Nobody notices a frame that is slightly wrong. Everybody notices black.
This is three lines of code and it will not be written unless asked
for. The same is true of pre-decoding neighbours with
img.decode(). Without it the first upload of a fresh frame
decodes synchronously on the main thread during a scroll tick, which is
exactly where you cannot afford 20 ms.
Two textures, one shader, any two frames
Two sampler2D uniforms plus uMix so any two
frames — from the same sequence or across a boundary — can be
cross-dissolved.
The obvious renderer has one texture and draws the current frame. Then
every section boundary needs special handling, and the six transitions
get implemented as six separate code paths with six separate bugs.
Naming the two-texture design up front makes a section change and a
frame step the same operation: A is where we were, B is where we are
going, uMix is how far. A cross-dissolve inside one
sequence and a halftone wipe between two sequences differ only in which
function the shader runs on uMix.
"Reused textures, never allocate per frame" is in there because
allocating a texture per frame is what an agent writes first and it
leaks GPU memory until the tab dies at around frame 400. The
?t= query param is in there because you will want to see
every transition on every boundary before you decide which goes where,
and rebuilding for each is how tuning does not happen.
Generate motion, then decompose it
Do NOT try to generate 121 individually consistent frames with an image model; they will not match.
An agent with an image model at hand will try to prompt its way to a frame sequence, because it is the tool it has. The result looks like a sequence for exactly as long as it is not moving. The prompt puts the asset pipeline before the engine in the deliverables for a reason: the engine is easy to build against real sequences and easy to build wrong against imagined ones, and the ffmpeg script is where the quality, frame count and size budget get enforced by a number rather than by eye.
The "lower quality before lowering frame count" rule is the kind of thing that only survives if it is written down. Cutting from 121 frames to 60 halves the folder size and looks like the obvious lever. It also doubles the visual jump between adjacent frames, and below about 90 frames per five-second clip the scrub starts to strobe on fast scrolls. Quality 70 versus 78 is invisible under 17% film grain.
Text stays HTML
Never rendered into the canvas.
Once there is a fullscreen WebGL canvas, drawing the headlines into it
is tempting — the grain and grade would apply to type for free. It also
makes the site invisible to search engines, screen readers, find-in-page
and text selection, and it turns a font change into a shader change. The
SVG filter on specific text blocks gets you the organic warp without
giving any of that up, and absolutely positioned layers with
will-change: transform, opacity composite on the same GPU
as the canvas anyway.
Write the fallbacks as first-class features
Reduced motion, WebGL context loss, JS disabled, a 180-frame memory cap, a boot poster so the first paint does not wait on a shader compile: none of these appear in a site until they are asked for, and every one of them is the difference between a page that works for everyone and a demo that works on the machine it was built on. Reduced motion on a 60-viewport sticky stage without a fallback is a blank page you cannot scroll out of. Write them into the brief and they get built with the same care as the halftone.
What goes wrong, and what it means
-
Black flashes at section boundaries. The renderer is
drawing the wanted frame rather than
nearest(), or the incoming sequence'sreadyAtis 0. Check that a sequence cannot render until at least two frames have decoded. -
Stutter on every scroll direction change. A
<video>withcurrentTimesurvived somewhere. Search the build for it — it is usually the "hero reveal" the agent decided to scrub. -
The tab's memory climbs until it crashes around a minute
in. Textures are being allocated per frame instead of
uploaded into two reused ones, or the 180-frame cap and the
img.srcrelease were never implemented. - The scrub is smooth at the start of a sequence and stale in the middle. Frames are loading 1→N. The halving stride was replaced with a plain loop, probably during a refactor.
- A hitch every few frames while scrolling slowly. The neighbour pre-decode is missing and the first upload of each frame is decoding on the main thread. Or the global in-flight limit is missing and forty requests are competing.
-
The frame index and the headline are half a beat apart.
Something is reading
scrollYin a scroll listener instead of from the sharedp. There must be exactly one reader. -
Ambient loops do not autoplay on iPhone. The
mutedattribute is set in JS only, orplaysinlineis missing, or there is nopointerdownfallback. All three are required. - The scrub strobes on fast scrolls. A sequence was cut below ~90 frames to hit the size budget. Put the frames back and drop WebP quality instead.
- Six sequences look like six different sites. The shared style suffix was left off one or more of the still prompts. Regenerate the outliers; do not try to grade them into matching in the shader.
-
A hard edge on the ring transition on some machines.
GL_OES_standard_derivativesis unavailable and the fallback constant AA width was never written. - Blank page with reduced motion on. The 60-viewport stage is still tall and the scrub is disabled, so there is nothing to scroll into. The stage must collapse to document height with one still per section.
- The JS bundle is over budget. An animation or GL library was pulled in "just for the easing" or "just for the matrix math". There is no matrix math in a fullscreen quad.
Recap
-
Ban video scrubbing by name and specify the replacement in the same
sentence. "Scroll-driven video" is what every tutorial means by
currentTime, andcurrentTimeis what stutters. -
One progress number, one loop, one reader of
scrollY. Everything computed from the same value is in sync by construction. - Specify the load order. Halving stride for the schedule, a priority window around the wanted frame for the user, and a nearest-decoded fallback so nothing is ever black.
- Design the renderer around two textures and a mix, so that a frame step and a section change are the same operation.
- Generate motion, then decompose it. One still per scene, one video model for consistency, one ffmpeg script that enforces the budget with a number.
- Lower quality before frame count. Grain hides compression; nothing hides strobing.
- Keep text as HTML. The canvas is for pictures.
- Write the fallbacks into the brief — reduced motion, context loss, no JS, memory cap, boot poster — or they will not exist.
- Ask for the size report and the first single-sequence scrub before anything else. Both are checkpoints you can fail on observation.
This is the second brief in a pair. The first was a scroll-choreographed site built entirely out of transforms and clip-paths, with no canvas at all — the premium property site prompt — and the teardown it came from. If you want more prompts in this shape, there is a frosted-glass refraction cube in WebGL, a kinetic wind wall in a vertex shader, and a floor lamp hero with a brass switch that lights the whole page.