All components

Infinite Horizontal Scroll

JavaScript animation component · Published 2026-07-27 · by vanguardia.dev

Open live demo ↗ Raw prompt (.md)

What it does

A seamless looping horizontal scroll gallery driven by raw wheel and touch input. Vertical wheel/touch deltas are mapped to horizontal movement with a lerp-smoothed translateX, cloned section buffers on both sides create an endless loop, and a synced progress bar and percentage counter track position.

How it's built

Categoryscroll
Techvanilla JS
Complexitypage
Performance costlight
Mobile-safeyes

infinite-scroll horizontal-scroll marquee loop lerp vanilla-js requestanimationframe editorial bold

Rebuild it with AI

To reproduce this animation in your own project, copy the prompt below into Claude Code, Cursor or any AI coding agent. The prompt is validated — it describes the exact structure, timing and easing, so the agent rebuilds the effect faithfully and you can then adapt colors, copy and layout to your design.

The full prompt

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 , serif fallback (or leave the family bare) and never substitute a sans fallback such as "Arial Narrow", "Impact", or sans-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; (its h1 inherits 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, .story use display: flex; justify-content: space-between; align-items: flex-start;, and .intro, .about, .story are flex-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;; each p inside is width: 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)
  1. Remove any existing .clone-section elements, then collect the original sections.
  2. Compute sequenceWidth = the sum of each original section's computed pixel width (parseFloat(getComputedStyle(section).width)).
  3. Append full cloned copies of the whole sequence: bufferSize copies before-equivalents and bufferSize copies after (loop i from -bufferSize to -1, then 1 to bufferSize; for each, clone every section with cloneNode(true), add class clone-section and attribute data-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.
  4. Set scroller.style.width = sequenceWidth * (1 + bufferSize * 2) + "px" (5 sequence copies total).
  5. Initialize targetScrollX = currentScrollX = sequenceWidth * bufferSize and apply scroller.style.transform = translateX(-currentScrollX px). Return sequenceWidth.
2. Boundary wrap (checkBoundaryAndReset)

Keeps the strip centered on the middle copy so the loop never ends:

  • If currentScrollX > sequenceWidth * (bufferSize + 0.5) → subtract sequenceWidth from BOTH targetScrollX and currentScrollX, re-apply the transform immediately, return true.
  • If currentScrollX < sequenceWidth * (bufferSize - 0.5) → add sequenceWidth to both, re-apply, return true.
  • 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) and targetProgressScale = percentage / 100.
  • Wrap detection: if (lastPercentage > 80 && percentage < 20) or (lastPercentage < 20 && percentage > 80) or forceReset → snap instantly: currentProgressScale = targetProgressScale and apply scaleX(currentProgressScale) directly (skipping the lerp for that jump).
  • Store lastPercentage = percentage.
4. Animation loop (animate(sequenceWidth, forceProgressReset = false))

Each frame:

  1. currentScrollX = lerp(currentScrollX, targetScrollX, 0.05) and apply translateX(-currentScrollX px) to the scroller.
  2. Call updateProgress.
  3. Unless forceProgressReset is true for this frame, currentProgressScale = lerp(currentProgressScale, targetProgressScale, 0.05) and apply scaleX(currentProgressScale) to the progress bar.
  4. If Math.abs(targetScrollX - currentScrollX) < 0.01 set isAnimating = false and stop; otherwise requestAnimationFrame(() => 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(), then targetScrollX += e.deltaY (vertical wheel → horizontal movement, 1:1). Call checkBoundaryAndReset; if not already animating, set isAnimating = true and start requestAnimationFrame(() => animate(sequenceWidth, needsReset)).
  • touchstart: set isDown = true, record lastTouchX = e.touches[0].clientX and lastTouchTime = Date.now(), and kill inertia with targetScrollX = currentScrollX.
  • touchmove: if not down return; e.preventDefault(); touchDelta = lastTouchX - currentTouchX; targetScrollX += touchDelta * touchSensitivity (2.5×). Track velocity: with timeDelta = now - lastTouchTime, if positive, touchVelocity = (touchDelta / timeDelta) * 15. Update lastTouchX / 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 frame touchVelocity *= 0.95, and while |touchVelocity| > 0.1 keep adding touchVelocity to targetScrollX, run checkBoundaryAndReset, and if it wrapped call updateProgress(sequenceWidth, true); continue via requestAnimationFrame.

Assets / images

4 photographs, referenced from the HTML:

  1. 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).
  2. About inline image — a photo displayed at a 7:5 landscape aspect ratio next to the two text columns inside the red about panel.
  3. Banner panel image — a second full-bleed photo filling a 75vw × full-height panel.
  4. 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 translateX on 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 (not 100vh) 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-face any font, and never fall back to a sans/condensed face; adding a font or a sans fallback makes the reproduction diverge from the original.