Infinite Horizontal Scroll
Goal
Build a full-page, seamlessly looping horizontal scroll experience: a row of 8 editorial panels that scrolls sideways forever in either direction. Vertical mouse-wheel deltas (and horizontal touch drags) are converted into smooth, lerp-eased horizontal movement of a giant flex strip. Cloned copies of the panel sequence on both sides make the loop invisible, and a fixed progress bar plus a percentage counter track the position within one loop (0–100).
Tech
Vanilla HTML/CSS/JS with an ES module script (<script type="module" src="./script.js">). No GSAP and no external libraries are needed — the entire effect is a hand-rolled lerp + requestAnimationFrame loop with native wheel / touch event listeners. Do not import gsap or lenis.
Layout / HTML
Everything lives inside a fixed, viewport-sized container that hides overflow:
.container (fixed wrapper)
├── .progress-bar (empty div, fixed top bar)
├── .progress-counter (fixed bottom-right) > h1 with initial text "0"
└── .scroller (the horizontal flex strip)
├── section.intro h1 "Once You Start Scrolling, There’s No Way Out!"
│ (note the curly typographer’s apostrophe ’ in "There’s", not a straight ')
│ h2 "What If Your Website Could Scroll Forever?"
├── section.hero-img > img (full-bleed photo)
├── section.header h1 "Traversing the frontier of digital evolution, crafting the future."
├── section.about > .row > .copy (two <p> of futuristic studio copy)
│ > .img > img
│ then h1 "Future Architectonics"
├── section.banner-img > img (full-bleed photo)
├── section.story four h1s: "Digital Alchemy", "Neoteric Identities",
│ "Cinematic Realities", "Symphonics"
├── section.concept-img > img (full-bleed photo)
└── section.outro h1 "horizons.com"
The two .about paragraphs are short marketing copy about engineering next-generation digital experiences (2–3 sentences each; any similar futuristic-studio prose works).
Styling
- Global reset:
* { margin: 0; padding: 0; box-sizing: border-box; user-select: none; }. - Typography — read carefully; the fonts are deliberately NOT loaded
- None of the named webfonts below are bundled or linked: there is no
@font-face, no<link>, and no import for any font. This is intentional and must be preserved — do NOT add a Google Fonts<link>, an@font-face, or a self-hosted font. The named families simply never resolve, so the browser substitutes its default serif (a Times-like face) for every heading and paragraph. That default serif — rendered at normal letter width, NOT condensed — is exactly what the original demo shows and what the reproduction must match. h1:font-family: "Druk Condensed", serif. "Druk Condensed" is the *intended* ultra-condensed display face, but because it is never loaded the giant headings actually render in the browser's default serif at normal width. Keep the, seriffallback (or leave the family bare) and never substitute a sans fallback such as"Arial Narrow","Impact", orsans-serif— a sans headline is the single biggest divergence from the original. Other h1 properties:font-size: 15vw,font-weight: lighter,text-transform: uppercase,line-height: 0.8,padding-top: 0.2em.h2:font-family: "Saans TRIAL"(likewise unbundled → falls back to the same default serif; do not add a sans/Helvetica fallback),font-size: 30px,font-weight: 700.p:font-family: "Saans TRIAL"(same unbundled family → default serif),font-size: 15px,font-weight: 500.img { width: 100%; height: 100%; object-fit: cover; }..container:position: fixed; top: 0; left: 0; width: 100vw; height: 100svh; overflow: hidden;..progress-bar:position: fixed; top: 0; left: 0; width: 100vw; height: 10px; transform: scaleX(0); transform-origin: center left; background-color: #fff; will-change: transform; z-index: 2;..progress-counter:position: fixed; bottom: 1em; right: 2.5em; color: #fff; z-index: 2;(itsh1inherits the giant 15vw display style)..scroller:position: relative; width: 700vw; height: 100svh; display: flex; will-change: transform; transform: translateX(0);(the width is overridden in px by JS after cloning).- Every
section:position: relative; height: 100svh; display: flex; justify-content: center; align-items: center;. - Section widths:
.intro, .hero-img, .about, .banner-img, .story, .outro { width: 75vw; }and.header, .concept-img { width: 100vw; }. - Panel colors:
.intro, .header:padding: 2em; background-color: #000; color: #fff;..about:padding: 4em 3em 1em 2em; background-color: #eb001b;(vivid red),color: #fff..story:padding: 4em 2em 2em 2em; background-color: #f69e1c;(amber)..outro:background-color: #fe5e00;(bright orange)..about, .intro, .header, .storyusedisplay: flex; justify-content: space-between; align-items: flex-start;, and.intro, .about, .storyareflex-direction: column(so the big heading sits at the bottom of.about, and the intro's h1/h2 spread top/bottom)..header h1 { font-size: 15.75vw; }and.story h1 { padding-top: 0; }..about .row:display: flex; justify-content: space-between;; eachpinside iswidth: 50%; margin-bottom: 1em;;.copy { flex: 3; };.img { flex: 2; aspect-ratio: 7/5; }.
Scroll engine (the core effect — implement exactly)
All logic runs inside DOMContentLoaded. Constants:
smoothFactor = 0.05— the lerp interpolation factor used for both the strip position and the progress bar.touchSensitivity = 2.5— multiplier applied to touch drag deltas.bufferSize = 2— number of cloned sequence copies on EACH side of the originals.lerp(start, end, factor) => start + (end - start) * factor.
State: targetScrollX, currentScrollX, isAnimating flag, currentProgressScale, targetProgressScale, lastPercentage, plus touch state (isDown, lastTouchX, touchVelocity, lastTouchTime).
1. Setup (setupScroll)
- Remove any existing
.clone-sectionelements, then collect the originalsections. - Compute
sequenceWidth= the sum of each original section's computed pixel width (parseFloat(getComputedStyle(section).width)). - Append full cloned copies of the whole sequence:
bufferSizecopies before-equivalents andbufferSizecopies after (loopifrom-bufferSizeto-1, then1tobufferSize; for each, clone every section withcloneNode(true), add classclone-sectionand attributedata-clone-index="{i}-{index}", and append to.scroller). The DOM order ends up: originals first, then all clones appended after — this still works because the strip is translated to the middle copy. - Set
scroller.style.width = sequenceWidth * (1 + bufferSize * 2) + "px"(5 sequence copies total). - Initialize
targetScrollX = currentScrollX = sequenceWidth * bufferSizeand applyscroller.style.transform = translateX(-currentScrollX px). ReturnsequenceWidth.
2. Boundary wrap (checkBoundaryAndReset)
Keeps the strip centered on the middle copy so the loop never ends:
- If
currentScrollX > sequenceWidth * (bufferSize + 0.5)→ subtractsequenceWidthfrom BOTHtargetScrollXandcurrentScrollX, re-apply the transform immediately, returntrue. - If
currentScrollX < sequenceWidth * (bufferSize - 0.5)→ addsequenceWidthto both, re-apply, returntrue. - Otherwise return
false. The returned flag is used to force-snap the progress bar (no lerp glitch across the wrap).
3. Progress (updateProgress(sequenceWidth, forceReset))
currentPosition = (currentScrollX - sequenceWidth * bufferSize) % sequenceWidth;percentage = currentPosition / sequenceWidth * 100; if negative,percentage = 100 + percentage.- Set the counter text to
Math.round(percentage)andtargetProgressScale = percentage / 100. - Wrap detection: if (
lastPercentage > 80 && percentage < 20) or (lastPercentage < 20 && percentage > 80) orforceReset→ snap instantly:currentProgressScale = targetProgressScaleand applyscaleX(currentProgressScale)directly (skipping the lerp for that jump). - Store
lastPercentage = percentage.
4. Animation loop (animate(sequenceWidth, forceProgressReset = false))
Each frame:
currentScrollX = lerp(currentScrollX, targetScrollX, 0.05)and applytranslateX(-currentScrollX px)to the scroller.- Call
updateProgress. - Unless
forceProgressResetis true for this frame,currentProgressScale = lerp(currentProgressScale, targetProgressScale, 0.05)and applyscaleX(currentProgressScale)to the progress bar. - If
Math.abs(targetScrollX - currentScrollX) < 0.01setisAnimating = falseand stop; otherwiserequestAnimationFrame(() => animate(sequenceWidth))(subsequent frames run without the force flag).
On load: run setupScroll(), then updateProgress(sequenceWidth, true) and apply the initial scaleX.
5. Input handlers (all on .container)
- Wheel (
{ passive: false }):e.preventDefault(), thentargetScrollX += e.deltaY(vertical wheel → horizontal movement, 1:1). CallcheckBoundaryAndReset; if not already animating, setisAnimating = trueand startrequestAnimationFrame(() => animate(sequenceWidth, needsReset)). - touchstart: set
isDown = true, recordlastTouchX = e.touches[0].clientXandlastTouchTime = Date.now(), and kill inertia withtargetScrollX = currentScrollX. - touchmove: if not down return;
e.preventDefault();touchDelta = lastTouchX - currentTouchX;targetScrollX += touchDelta * touchSensitivity(2.5×). Track velocity: withtimeDelta = now - lastTouchTime, if positive,touchVelocity = (touchDelta / timeDelta) * 15. UpdatelastTouchX/lastTouchTime, run the boundary check, and kick the animate loop like the wheel handler. - touchend:
isDown = false. If|touchVelocity| > 0.1, apply a momentum fling:targetScrollX += touchVelocity * 20, then start a rAF decay loop — each frametouchVelocity *= 0.95, and while|touchVelocity| > 0.1keep addingtouchVelocitytotargetScrollX, runcheckBoundaryAndReset, and if it wrapped callupdateProgress(sequenceWidth, true); continue viarequestAnimationFrame.
Assets / images
4 photographs, referenced from the HTML:
- Hero panel image — a striking full-bleed editorial/architectural photo filling a 75vw × full-viewport-height panel (roughly 3:4 to 4:5 of viewport, cropped with
object-fit: cover). - About inline image — a photo displayed at a 7:5 landscape aspect ratio next to the two text columns inside the red about panel.
- Banner panel image — a second full-bleed photo filling a 75vw × full-height panel.
- Concept panel image — a third full-bleed photo filling a 100vw × full-height panel.
Any cohesive set of moody, futuristic/editorial photographs works; they are cropped by the panels.
Behavior notes
- There is NO native page scroll: the document never scrolls; the fixed container intercepts wheel/touch and drives
translateXon the strip. Body needs no extra height. - The loop is bidirectional and endless — scrolling backward from the start lands on the end of the sequence with no visible jump.
- The counter and bar wrap from 100 back to 0 (and vice versa); the snap logic prevents the bar from animating backward across the seam.
- Works on touch devices via the drag + momentum handlers; desktop uses the wheel.
100svh(not100vh) is used for all heights to behave correctly in mobile browsers.- Fonts (do not "improve" this): no webfonts are loaded, so all text — including the giant 15vw headings — renders in the browser's default serif at normal width. Reproduce this exactly. Declare the family names as given (
"Druk Condensed", serif/"Saans TRIAL") but do not link, import, or@font-faceany font, and never fall back to a sans/condensed face; adding a font or a sans fallback makes the reproduction diverge from the original.