Smooth, Infinite, Horizontal Scroller with Progress Tracking

Category: Javascript , Slider | September 17, 2025
Author:haptichash
Views Total:73 views
Official Page:Go to website
Last Update:September 17, 2025
License:MIT

Preview:

Smooth, Infinite, Horizontal Scroller with Progress Tracking

Description:

This is a Vanilla JavaScript horizontal scroller that helps you display content in a modern, continuous way.

It combines hardware-accelerated CSS transforms with requestAnimationFrame optimization to deliver consistent 60fps performance across desktop and mobile devices.

Features:

  • Clone-based infinite looping: It dynamically duplicates content sections to create a continuous loop without any visual jumps or resets.
  • Touch and wheel support: Natively handles touch gestures with momentum and velocity calculations, as well as standard mouse wheel inputs.
  • Real-time progress tracking: Includes a visual progress bar and a percentage counter that update as you scroll.

See it in action:

Use Cases:

  • Portfolio showcases: An excellent way to display a series of projects or case studies in a continuous, interactive gallery.
  • Product galleries: Ideal for e-commerce sites to feature multiple products or product views in a single, horizontally scrollable section. This avoids vertical clutter on a landing page.
  • Interactive storytelling: Use it to guide users through a narrative or a step-by-step process on a landing page, where each section reveals a new part of the story.
  • Image carousels: A modern, smooth alternative to traditional carousels, especially for hero sections or visual-heavy landing pages.

How to use it:

1. Create the HTML for the scroller. The structure consists of a main .container, a .progress-bar and .progress-counter for the UI, and a .scroller that holds all your content section elements.

<div class="container">
  <div class="progress-bar"></div>
  <div class="progress-counter">
    <h1>0</h1>
  </div>
  <div class="scroller">
    <!-- Your content sections go here -->
    <section class="intro">
      <h1>Designing the Future One Scroll at a Time</h1>
    </section>
    <section class="hero-img">
      <img src="your-image-url.jpg" alt="Description" />
    </section>
    <!-- Add as many sections as you need -->
  </div>
</div>

2. Add the CSS to style the layout. The key parts are making the .container a fixed element that fills the viewport and hiding the overflow. The .scroller is a flex container with a very large width to hold all the sections in a single row.

* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}
h1 {
  font-family: "Anton", sans-serif;
  font-size: 10vw;
  font-weight: 400;
  text-transform: uppercase;
  line-height: 1;
  letter-spacing: -2px;
  padding-top: 0.25rem;
}
h2 {
  font-family: "Poppins", sans-serif;
  font-size: 2.5rem;
  font-weight: 700;
}
p {
  font-family: "Poppins", sans-serif;
  font-size: 1rem;
  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: 8px;
  transform: scaleX(0%);
  transform-origin: center left;
  background-color: #fff;
  will-change: transform;
  z-index: 2;
}
.progress-counter {
  position: fixed;
  bottom: 1rem;
  right: 2.5rem;
  color: #fff;
  z-index: 2;
  text-shadow: 5px 5px 15px rgb(0 0 0 / 15%);
}
.scroller {
  position: relative;
  width: 700vw;
  height: 100svh;
  display: flex;
  will-change: transform;
  transform: translateX(0);
}
section {
  position: relative;
  height: 100svh;
  display: flex;
  justify-content: center;
  align-items: center;
}
.intro,
.hero-img,
.about,
.banner-img,
.story,
.outro {
  width: 90vw;
}
.header,
.concept-img {
  width: 100vw;
}
.intro {
  padding: 2rem;
  background-color: #000;
  color: #f6f6f6;
}
.header {
  padding: 2rem;
  background-color: #f4d35e;
  color: #421c06;
}
.about {
  padding: 4rem 3rem 1rem 2rem;
  background-color: #f95738;
  color: #481107;
}
.story {
  padding: 4rem 2rem 2rem 2rem;
  background-color: #ee964b;
  color: #3f170b;
}
.outro {
  background-color: #ebebd3;
  color: #302418;
  padding: 2rem;
}
.about,
.intro,
.header,
.story {
  display: flex;
  justify-content: space-between;
  align-items: flex-start;
}
.intro,
.about,
.story {
  flex-direction: column;
}
.header h1 {
  font-size: 10vw;
}
.story h1 {
  padding-top: 0;
}
.about .row {
  display: flex;
  justify-content: space-between;
}
.about .row p {
  width: 50%;
  margin-bottom: 1rem;
}
.about .row .copy {
  flex: 3;
}
.about .row .img {
  flex: 3;
  aspect-ratio: 7/5;
}

3. Add the JavaScript to enable the scroller. You can adjust a few constants at the top of the script to fine-tune the behavior:

  • smoothFactor: Controls the animation’s smoothness. A smaller value (e.g., 0.05) results in a smoother, more gradual scroll. A larger value makes it more responsive but less fluid.
  • touchSensitivity: A multiplier for how much the scroller moves in response to touch gestures.
  • bufferSize: Determines how many sets of your content are cloned on either side of the original content to create the infinite loop. The default of 2 is usually sufficient.
document.addEventListener("DOMContentLoaded", () => {
  const container = document.querySelector(".container");
  const scroller = document.querySelector(".scroller");
  const progressCounter = document.querySelector(".progress-counter h1");
  const progressBar = document.querySelector(".progress-bar");
  const sections = Array.from(scroller.querySelectorAll("section"));
  const smoothFactor = 0.05;
  const touchSensitivity = 2.5;
  const bufferSize = 2;
  let targetScrollX = 0;
  let currentScrollX = 0;
  let isAnimating = false;
  let currentProgressScale = 0;
  let targetProgressScale = 0;
  let lastPercentage = 0;
  let isDown = false;
  let lastTouchX = 0;
  let touchVelocity = 0;
  let lastTouchTime = 0;
  const lerp = (start, end, factor) => start + (end - start) * factor;
  const setupScroll = () => {
    scroller
      .querySelectorAll(".clone-section")
      .forEach((clone) => clone.remove());
    const originalSections = Array.from(
      scroller.querySelectorAll("section:not(.clone-section)")
    );
    const templateSections =
      originalSections.length > 0 ? originalSections : sections;
    let sequenceWidth = 0;
    templateSections.forEach((section) => {
      sequenceWidth += parseFloat(window.getComputedStyle(section).width);
    });
    // Create clones before original sections
    for (let i = -bufferSize; i < 0; i++) {
      templateSections.forEach((section, index) => {
        const clone = section.cloneNode(true);
        clone.classList.add("clone-section");
        clone.setAttribute("data-clone-index", `${i}-${index}`);
        scroller.appendChild(clone);
      });
    }
    // Add original sections if none exist
    if (originalSections.length === 0) {
      templateSections.forEach((section, index) => {
        const clone = section.cloneNode(true);
        clone.setAttribute("data-clone-index", `0-${index}`);
        scroller.appendChild(clone);
      });
    }
    // Create clones after original sections
    for (let i = 1; i <= bufferSize; i++) {
      templateSections.forEach((section, index) => {
        const clone = section.cloneNode(true);
        clone.classList.add("clone-section");
        clone.setAttribute("data-clone-index", `${i}-${index}`);
        scroller.appendChild(clone);
      });
    }
    scroller.style.width = `${sequenceWidth * (1 + bufferSize * 2)}px`;
    targetScrollX = sequenceWidth * bufferSize;
    currentScrollX = targetScrollX;
    scroller.style.transform = `translateX(-${currentScrollX}px)`;
    return sequenceWidth;
  };
  const checkBoundaryAndReset = (sequenceWidth) => {
    if (currentScrollX > sequenceWidth * (bufferSize + 0.5)) {
      targetScrollX -= sequenceWidth;
      currentScrollX -= sequenceWidth;
      scroller.style.transform = `translateX(-${currentScrollX}px)`;
      return true;
    }
    if (currentScrollX < sequenceWidth * (bufferSize - 0.5)) {
      targetScrollX += sequenceWidth;
      currentScrollX += sequenceWidth;
      scroller.style.transform = `translateX(-${currentScrollX}px)`;
      return true;
    }
    return false;
  };
  const updateProgress = (sequenceWidth, forceReset = false) => {
    const basePosition = sequenceWidth * bufferSize;
    const currentPosition = (currentScrollX - basePosition) % sequenceWidth;
    let percentage = (currentPosition / sequenceWidth) * 100;
    if (percentage < 0) {
      percentage = 100 + percentage;
    }
    const isWrapping =
      (lastPercentage > 80 && percentage < 20) ||
      (lastPercentage < 20 && percentage > 80) ||
      forceReset;
    progressCounter.textContent = `${Math.round(percentage)}`;
    targetProgressScale = percentage / 100;
    if (isWrapping) {
      currentProgressScale = targetProgressScale;
      progressBar.style.transform = `scaleX(${currentProgressScale})`;
    }
    lastPercentage = percentage;
  };
  const animate = (sequenceWidth, forceProgressReset = false) => {
    currentScrollX = lerp(currentScrollX, targetScrollX, smoothFactor);
    scroller.style.transform = `translateX(-${currentScrollX}px)`;
    updateProgress(sequenceWidth, forceProgressReset);
    if (!forceProgressReset) {
      currentProgressScale = lerp(
        currentProgressScale,
        targetProgressScale,
        smoothFactor
      );
    }
    progressBar.style.transform = `scaleX(${currentProgressScale})`;
    if (Math.abs(targetScrollX - currentScrollX) > 0.01) {
      requestAnimationFrame(() => animate(sequenceWidth));
    } else {
      isAnimating = false;
    }
  };
  // Initialize
  const sequenceWidth = setupScroll();
  updateProgress(sequenceWidth, true);
  progressBar.style.transform = `scaleX(${currentProgressScale})`;
  // Wheel event
  container.addEventListener(
    "wheel",
    (e) => {
      e.preventDefault();
      targetScrollX += e.deltaY;
      const needsReset = checkBoundaryAndReset(sequenceWidth);
      if (!isAnimating) {
        isAnimating = true;
        requestAnimationFrame(() => animate(sequenceWidth, needsReset));
      }
    },
    { passive: false }
  );
  // Touch events
  container.addEventListener("touchstart", (e) => {
    isDown = true;
    lastTouchX = e.touches[0].clientX;
    lastTouchTime = Date.now();
    targetScrollX = currentScrollX;
  });
  container.addEventListener("touchmove", (e) => {
    if (!isDown) return;
    e.preventDefault();
    const currentTouchX = e.touches[0].clientX;
    const touchDelta = lastTouchX - currentTouchX;
    targetScrollX += touchDelta * touchSensitivity;
    const currentTime = Date.now();
    const timeDelta = currentTime - lastTouchTime;
    if (timeDelta > 0) {
      touchVelocity = (touchDelta / timeDelta) * 15;
    }
    lastTouchX = currentTouchX;
    lastTouchTime = currentTime;
    const needsReset = checkBoundaryAndReset(sequenceWidth);
    if (!isAnimating) {
      isAnimating = true;
      requestAnimationFrame(() => animate(sequenceWidth, needsReset));
    }
  });
  container.addEventListener("touchend", () => {
    isDown = false;
    if (Math.abs(touchVelocity) > 0.1) {
      targetScrollX += touchVelocity * 20;
      const decayVelocity = () => {
        touchVelocity *= 0.95;
        if (Math.abs(touchVelocity) > 0.1) {
          targetScrollX += touchVelocity;
          const needsReset = checkBoundaryAndReset(sequenceWidth);
          if (needsReset) {
            updateProgress(sequenceWidth, true);
          }
          requestAnimationFrame(decayVelocity);
        }
      };
      requestAnimationFrame(decayVelocity);
    }
  });
});

FAQs:

Q: How does the infinite scrolling handle different section widths?
A: The component calculates each section’s computed width dynamically and adjusts the total sequence width accordingly. Sections can have different widths using CSS classes or inline styles.

Q: Does this work with dynamically added content?
A: The current implementation requires reinitialization if you add or remove sections after initial load. Call setupScroll() again after DOM modifications to recalculate dimensions and update clones.

You Might Be Interested In:


One thought on “Smooth, Infinite, Horizontal Scroller with Progress Tracking

Leave a Reply