An infinite generative artwork in a single SVG file — five parallax layers scrolling forever, with no visible loop point
Features · Getting Started · The Layers · How It Works · Tuning · Tech Stack
Most "infinite" web backgrounds are a tiled PNG dragged across the screen, and you can always find the seam if you stare long enough. Endless Scroll has no images, no JavaScript, and no build step — three glowing threads, a contour field, constellations and drifting dust are generated as pure geometry and scrolled by five CSS keyframes at five different speeds.
The loop is silent by construction, not by crossfade. Every layer is a 1200px tile drawn twice and translated by exactly one tile height, and every piece of content in that tile is built to be periodic in y with period 1200 — the meandering threads are sums of sine harmonics of 2πky/H, so a thread's end joins its own start with matching position and slope. Park a screen recorder on it for an hour and there is no frame where the artwork restarts.
- Genuinely seamless — the tile boundary is mathematically continuous, not blended. Threads, contour lines and drifting dust all pass straight through the seam mid-frame
- Five-layer parallax — dust, contours, constellations, threads and ring geometry each scroll one tile height over their own duration (150s → 30s), so depth comes out of timing alone
- Zero assets, zero JS — one 71 KB
.svgfile. No textures, no fonts, no libraries, nothing to load. Open it straight from the filesystem - Living detail — glowing motes travel along the threads via
animateMotion, counter to the scroll, so the threads read as currents instead of wallpaper - Composed like a window — a gradient mask fades the top and bottom 9%, a radial vignette closes the corners, and a slow 14s bloom breathes behind everything
- Responsive by default —
preserveAspectRatio="xMidYMid slice"makes it cover any viewport, portrait or landscape, without letterboxing - Respects
prefers-reduced-motion— the scroll stops for anyone who has asked their OS for less animation - Regenerable —
generate.pyis the source of truth. Change the seed for a completely new composition, or tune the palette, densities and speeds in one place
1. Clone
git clone https://github.com/markksantos/endless-scroll.git
cd endless-scroll2. Open it
open endless-scroll.svg # macOS — any modern browser worksThere is no server and no build step. The file is self-contained.
3. Regenerate it (optional)
python3 generate.pyStandard library only — no pip install. It rewrites endless-scroll.svg in place.
<!-- as a full-bleed background -->
<img src="endless-scroll.svg" alt="" style="position:fixed;inset:0;width:100%;height:100%;object-fit:cover">CSS animations and SMIL both run inside an <img>, so it animates without being inlined. Inline the markup instead if you want to restyle the layers from the host page.
| Layer | Duration | Content | Reads as |
|---|---|---|---|
dust |
150s | 300 stars, r 0.4–1.9 | Deep background |
contour |
84s | 30 topographic lines, two harmonics each | Terrain haze |
const |
62s | 6 constellations, nearest-neighbour linked | Mid-distance |
thread |
46s | 3 meanders + halo strokes + 15 motes | The subject |
ring |
30s | 7 concentric ring pairs | Foreground drift |
Same tile height for all five, different durations — that difference is the parallax. At 11.5s into the loop the layers sit at −92px, −164px, −223px, −300px and −460px respectively.
generate.py
├─ meander() → 3 vertical threads, harmonics of 2πky/H only
├─ contours → 30 polylines, spacing 40px (divides H)
├─ dust → seeded random field, seed = 7
├─ constellations → clusters kept 70px off the seam
└─ rings → sparse foreground geometry
↓
<defs> holds each layer as a <g id="tile-*">
↓
each layer = <use> + <use transform="translate(0,1200)">
↓
@keyframes scroll { to { transform: translateY(-1200px) } }
A few details worth knowing before editing the generator:
- Only harmonics of
2πky/Hare allowed in anything that crosses the seam. A thread'sx(y)iscx + Σ aₖ sin(2πky/H + φₖ)— periodic in H, so position and slope match where the copies meet. Add a term that isn't an integer harmonic and the seam appears as a kink every loop. - Contour spacing must divide the tile height. 40px into 1200 is 30 lines; something like 45px leaves a short row at the bottom that reads as a visible band.
- Per-line variation is driven by
phase = 2πy₀/H, so amplitude and wave phase come back to their starting values at the seam. Varying them with rawy₀instead is the easy way to break an otherwise perfect tile. - Constellations stay 70px inside the tile. Their linking lines aren't wrapped, so a cluster straddling the boundary would be sliced in half. Threads and dust don't need the margin — a shape drawn past the edge simply reappears from its second copy.
- No layer group is clipped. Clipping the tiles would cut the shapes that legitimately overhang the boundary, which is exactly what makes the wrap invisible.
- Glow is two strokes, not a filter. A wide low-opacity blurred pass under a thin bright pass costs far less than a full-size
feGaussianBlur, which is expensive when it covers the whole viewport.
Everything worth changing lives at the top of generate.py:
| Knob | Default | Effect |
|---|---|---|
random.seed(7) |
7 |
Whole new composition of dust, constellations and rings |
W, H |
900, 1200 |
Tile size — H is also the scroll distance |
THREADS |
3 entries | Position, colour, weight, glow and harmonics per thread |
LAYERS durations |
150 / 84 / 62 / 46 / 30 |
Parallax spread; larger gaps read as more depth |
SPACING |
40 |
Contour density — must divide H |
#sky, #bloom, #vig |
dark blue | Background gradients, edited directly in the SVG template |
Five composited translateY animations and no per-frame layout, so the whole thing runs on the compositor — effectively free, and it stays at display refresh rate on a phone. The only main-thread cost is the initial parse of ~71 KB of path data.
Note for anyone screenshotting it in CI: headless Chrome does not repaint compositor-driven transforms into --screenshot, so a timed capture looks frozen. Seek the animations first with getAnimations()[0].currentTime = ms and the frame renders correctly.
| What | How |
|---|---|
| Artwork | Hand-rolled SVG geometry — paths, polylines, circles |
| Scroll | CSS @keyframes on five layer groups |
| Motes | SMIL animateMotion + mpath along the thread paths |
| Glow | Layered strokes plus one small feGaussianBlur |
| Framing | Gradient mask + radial vignette |
| Generator | Python 3, standard library only |
| Build | None |
MIT © Mark Santos
