Documentation
CubeScroll turns one image into a finished scroll effect. Drop a photograph or logo into the studio and it hands back complete, self-contained code: a page where the image begins as a cloud of scattered cubes and assembles itself, seamlessly, as the visitor scrolls. No build step, no framework, no dependencies beyond a pinned copy of three.js.
The trick
Every cube's front face is parked at a random depth z and scaled by
(ASSEMBLE_Z − z) / ASSEMBLE_Z. Apparent size on screen is then
size × scale ÷ distance — which cancels to a constant, whatever the depth.
So once the camera reaches ASSEMBLE_Z, every cube projects to exactly
the same screen rectangle and they tile into the photograph with no seams.
Scattered in depth, the same cubes read as noise. The scroll is nothing but
camera travel.
That whole transform is a function of three numbers — column, row, and parked depth — so three numbers are all an instance stores: 12 bytes per cube instead of a 64-byte matrix. The vertex shader derives everything else, which is why a grid of nearly a million cubes still loads instantly and draws in a single call.
Two ways in
The browser studio
Open the Studio. Drop, click or paste an image, tune it against a live preview, then download the standalone page or copy the JS module. The preview runs the literal export — the frame you scroll is byte-for-byte the file you download. (Above about 1.5 MB of embedded image the preview references the picture instead of inlining it, so the browser is not asked to parse the same megabytes twice; the download is unaffected.)
The CLI
node cli.mjs photo.jpgproduces the same output headlessly. That is what makes the effect scriptable — by build pipelines and by AI agents — instead of only forgeable by hand.
node cli.mjs photo.jpg # → photo-scroll.html
node cli.mjs photo.jpg --theme # → a whole landing page, themed from the image
node cli.mjs photo.jpg --rows 128 --emit json # sizing report, no file written
node cli.mjs photo.jpg --emit js --stdout # the bare module, for existing pages
node cli.mjs photo.jpg --probe 0,0.5,1 # + headless screenshots to verify
Two styles
--style picks which scene the module builds. Cubes is the
one above: a scatter that assembles. Kinetic is one plane carrying the
photograph on a shader — the picture is on screen the whole way down, displaced
by three octaves of noise and split into its colour channels, and the camera
pulls it into focus. Same camera, same clock, same seams; only the scene and
four parameters differ. See it on the
kinetic example.
--style cubes|kinetic— which scene the module builds. Defaultcubes.--flow 0–2— how far the noise smears the image at the top of the track. Default1; 0 is no displacement at all.--split 0–1— RGB channel separation at the top of the track. Default0.35; 1 is the chromatic screen-print register.--kinetics 0–1— how far the pointer parallaxes the image while the visitor reads. Default0.5; 0 pins it.--momentum 0–1— how far scroll speed stretches the picture along the scroll, deepens the smear and tears the colour split. Default0.5; 0 leaves the reveal answering to how far the page has scrolled and not to how fast.
All four are kinetic's own and are ignored by cubes, exactly as
--rows, --cube, --alpha, --faces
and --spread are ignored by kinetic. Switching styles never resets
the other style's numbers. --seed, --ease and
--stagger mean the same thing in both: the seed offsets the noise
field the way it parks the cubes, and stagger spreads the arrival across the
track by region instead of by cube.
Whichever style is running, the frame at the end of the track is the photograph: every displacement term scaled by progress reaches exactly zero at full assembly, the same way every cube reaches its seat. Two kinetic terms are not scaled by progress — the pointer lean and the momentum stretch, which are what keeps a resolved picture alive under the reader's hand — and both are exactly zero at rest, under a motion still, and in every frame an export draws.
Sizing: the grid and its tiers
Grid rows set how many cubes fly in. Instance count is rows × columns, and the studio reports it in tiers: ok (≤60,000 — comfortable anywhere), amber (60,000–200,000 — fine on desktops), red (over 200,000 — for audiences whose GPUs you know). With the default full-image faces, sharpness comes from the texture rather than the grid, so rows beyond ~256 rarely earn their cost.
Cube faces decide where the detail comes from. Full image hands the photograph to the GPU whole, giving every cube its own tile of it — the landed frame is as sharp as the source. Flat colour averages each tile to one colour: the chunky mosaic look, where the assembled image resolves to exactly the grid and no finer. Use flat faces for logos and pixel-art; pair with enough rows.
Themed pages
--theme composes a complete landing page around the effect instead
of leaving it bare. The colour system is extracted from the image itself —
quantized with median cut, derived in OKLCH, and held to WCAG AA by construction:
body text at least 4.5:1, accent at least 3:1 against its background, verified on
the final hex values. Three voices are built in: editorial (serif, airy),
technical (mono, gridded, with a live assembly readout), and
poster (condensed caps, solid accent blocks). See them on the
examples page.
Sections are paced against the assembly: the masthead lands while the image is still noise, body copy sits on panels over peak chaos, the quote waits for the image to resolve, and the call to action arrives over the finished photograph. The page still reads as designed over the flat background colour alone — which is exactly what renders if WebGL is unavailable.
Steering the palette
Four optional flags, applied in order, all landing upstream of the contrast enforcement — so none of them can break the guarantee:
--bg #rrggbb— force the page background; everything re-derives around it.--scheme dark|light— override what the image implies.--tint warm|cool|neutral|muted|vivid— a tonal steer over the derived neutrals and accent chroma.--accent #rrggbb— a brand accent. If it is already legible it is used verbatim; otherwise it moves as little as possible and the page's HTML comments record exactly what shifted and why.
Contrast is guaranteed by construction, with one honest limit: a
--bg pinned near mid-tone can be too close to the middle for either
white or black ink to clear 4.5:1. The palette picks whichever side does clear
it, so this only bites if you pin --scheme as well — and when the
floor genuinely cannot be met the page says so, in a CLI warning and in the
page's own HTML comments. It is never met silently.
If a page needs different colours, use these flags or change the image — never hand-edit the generated values.
Mounting the module in your own page
The JS module mounts its own fixed canvas at z-index: 0 with
pointer-events: none. Your page provides two things: visible content
in a wrapper with position: relative; z-index: 1, and real scroll
height — the assembly track is the emitted scroll length minus one viewport, so
anything parked below the track scrolls over the finished image.
The module publishes seams your page can ride:
--assembly— a custom property on:root, progress 0–1, republished only when it changes. Pace your own content against it and you are on the camera's clock.pixelScroll— aCustomEventcarrying the same number.--kinetic-xand--kinetic-y— the damped pointer, −1 to 1, on:rootbeside--assemblyand written in the same place. Only a kinetic page publishes them — a cubes page publishes neither, so read them asvar(--kinetic-x, 0)and one stylesheet serves both styles without guarding for a missing property. They are exactly 0 before the first pointer move, while a page holds the picture still, and while frames are being driven for an export.pixelScroll:motion— dispatch with{ still: true }to hold the picture assembled. It is OR-ed withprefers-reduced-motion, never swapped: a page toggle can add stillness, but the visitor's OS preference is the floor.data-forge-fallback— set on<html>when WebGL is unavailable or no three.js host is reachable. The photograph mounts as a still, assembly is published as 1, and content keyed to it lays out over a resolved image. Style around it; it is not an error state.
Accessibility and motion
Scroll-coupled whole-field motion is the canonical trigger for vestibular
discomfort, and the studio treats that as a design input, not a checkbox. Under
prefers-reduced-motion the camera does not travel: the photograph is
simply there, assembled, and progress is published as complete so paced content
shows its resting state. Themed pages also honour prefers-contrast
and forced-colors, keep body copy on panels opaque enough to hold
its contrast over any frame, and refuse to compose at all if an image is supplied
without alt text.
Verifying a forge
A clean exit code cannot tell you whether a headline survives the noise behind
it. --probe 0,0.5,1 opens the finished page headlessly, scrolls to
each assembly fraction, waits for the damped camera to settle, and writes
screenshots — reporting the engine's own published progress per frame and flagging
any frame that is off its requested fraction. Then you look at the pictures.
Frames near 0.5–0.7 of the track are where text fails first.