Marquee with Scroll Direction

A GSAP-powered marquee that inverts its travel direction based on scroll direction, adds a parallax speed-boost effect on scroll, and exposes a data-marquee-status attribute for CSS-driven directional styling.

marqueegsapscrolltriggerscroll-directionparallaxloopresponsive

Setup — External Scripts

GSAP 3.12.5
html
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/gsap.min.js"></script>
ScrollTrigger Plugin
html
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/ScrollTrigger.min.js"></script>

Code

index.html
html
<section class="section-resource">
  <!-- Text-based marquee (speed based on font size) -->
  <div data-marquee-duplicate="2" data-marquee-scroll-direction-target="" data-marquee-direction="left" data-marquee-status="normal" data-marquee-speed="15" data-marquee-scroll-speed="10" class="marquee-advanced">
    <div data-marquee-scroll-target="" class="marquee-advanced__scroll">
      <div data-marquee-collection-target="" class="marquee-advanced__collection">
        <div class="marquee-advanced__item">
          <p class="marquee__advanced__p">Marquee w/ Scroll Direction -</p>
        </div>
      </div>
    </div>
  </div>
  <!-- Item-width-based marquee -->
  <div data-marquee-duplicate="2" data-marquee-scroll-direction-target="" data-marquee-direction="right" data-marquee-status="normal" data-marquee-speed="15" data-marquee-scroll-speed="10" class="marquee-advanced">
    <div data-marquee-scroll-target="" class="marquee-advanced__scroll">
      <div data-marquee-collection-target="" class="marquee-advanced__collection">
        <div class="marquee-advanced__item-width"></div>
        <div class="marquee-advanced__item-width"></div>
        <div class="marquee-advanced__item-width"></div>
        <div class="marquee-advanced__item-width"></div>
        <div class="marquee-advanced__item-width"></div>
      </div>
    </div>
  </div>
</section>
styles.css
css
.section-resource {
  flex-flow: column;
  justify-content: center;
  align-items: center;
  min-height: 100vh;
  display: flex;
}

.marquee-advanced {
  width: 100vw;
  position: relative;
  overflow: hidden;
}

.marquee-advanced__scroll {
  will-change: transform;
  width: 100%;
  display: flex;
  position: relative;
}

.marquee-advanced__collection {
  will-change: transform;
  display: flex;
  position: relative;
}

.marquee-advanced__item {
  justify-content: flex-start;
  align-items: center;
  font-size: max(4em, 8vw);
  display: flex;
}

.marquee__advanced__p {
  white-space: nowrap;
  margin-bottom: 0;
  margin-right: .25em;
  font-size: 1em;
}

.marquee__advanced__arrow-svg {
  color: #ff4c24;
  width: 1em;
  margin-right: .25em;
  position: relative;
}

.marquee-advanced__item-width {
  background-color: #131313;
  border-radius: 1vw;
  justify-content: center;
  align-items: center;
  width: 18vw;
  height: 18vw;
  margin: 1vw;
  display: flex;
}

/* Optional: Rotating arrow left/right based on Scroll Direction */

.marquee__advanced__arrow-svg,
[data-marquee-direction="right"][data-marquee-status="inverted"] .marquee__advanced__arrow-svg {
  transition: 0.5s cubic-bezier(0.625, 0.05, 0, 1);
  transform: rotate(-180deg);
}

[data-marquee-status="inverted"] .marquee__advanced__arrow-svg,
[data-marquee-direction="right"][data-marquee-status="normal"] .marquee__advanced__arrow-svg {
  transform: rotate(-359.999deg);
}
script.js
javascript
function initMarqueeScrollDirection() {
  document.querySelectorAll('[data-marquee-scroll-direction-target]').forEach((marquee) => {
    // Query marquee elements
    const marqueeContent = marquee.querySelector('[data-marquee-collection-target]');
    const marqueeScroll = marquee.querySelector('[data-marquee-scroll-target]');
    if (!marqueeContent || !marqueeScroll) return;

    // Get data attributes
    const {
      marqueeSpeed: speed,
      marqueeDirection: direction,
      marqueeDuplicate: duplicate,
      marqueeScrollSpeed: scrollSpeed
    } = marquee.dataset;

    // Convert data attributes to usable types
    const marqueeSpeedAttr = parseFloat(speed);
    const marqueeDirectionAttr = direction === 'right' ? 1 : -1; // 1 for right, -1 for left
    const duplicateAmount = parseInt(duplicate || 0);
    const scrollSpeedAttr = parseFloat(scrollSpeed);
    const speedMultiplier = window.innerWidth < 479 ? 0.25 : window.innerWidth < 991 ? 0.5 : 1;

    let marqueeSpeed = marqueeSpeedAttr * (marqueeContent.offsetWidth / window.innerWidth) * speedMultiplier;

    // Precompute styles for the scroll container
    marqueeScroll.style.marginLeft = `${scrollSpeedAttr * -1}%`;
    marqueeScroll.style.width = `${(scrollSpeedAttr * 2) + 100}%`;

    // Duplicate marquee content
    if (duplicateAmount > 0) {
      const fragment = document.createDocumentFragment();
      for (let i = 0; i < duplicateAmount; i++) {
        fragment.appendChild(marqueeContent.cloneNode(true));
      }
      marqueeScroll.appendChild(fragment);
    }

    // GSAP animation for marquee content
    const marqueeItems = marquee.querySelectorAll('[data-marquee-collection-target]');
    const animation = gsap.to(marqueeItems, {
      xPercent: -100,
      repeat: -1,
      duration: marqueeSpeed,
      ease: 'linear'
    }).totalProgress(0.5);

    // Initialize marquee in the correct direction
    gsap.set(marqueeItems, { xPercent: marqueeDirectionAttr === 1 ? 100 : -100 });
    animation.timeScale(marqueeDirectionAttr);
    animation.play();

    // Set initial marquee status
    marquee.setAttribute('data-marquee-status', 'normal');

    // ScrollTrigger logic for direction inversion
    ScrollTrigger.create({
      trigger: marquee,
      start: 'top bottom',
      end: 'bottom top',
      onUpdate: (self) => {
        const isInverted = self.direction === 1; // Scrolling down
        const currentDirection = isInverted ? -marqueeDirectionAttr : marqueeDirectionAttr;

        animation.timeScale(currentDirection);
        marquee.setAttribute('data-marquee-status', isInverted ? 'normal' : 'inverted');
      }
    });

    // Extra speed effect on scroll
    const tl = gsap.timeline({
      scrollTrigger: {
        trigger: marquee,
        start: '0% 100%',
        end: '100% 0%',
        scrub: 0
      }
    });

    const scrollStart = marqueeDirectionAttr === -1 ? scrollSpeedAttr : -scrollSpeedAttr;
    const scrollEnd = -scrollStart;

    tl.fromTo(marqueeScroll, { x: `${scrollStart}vw` }, { x: `${scrollEnd}vw`, ease: 'none' });
  });
}

// Initialize Marquee with Scroll Direction
document.addEventListener('DOMContentLoaded', () => {
  initMarqueeScrollDirection();
});

Attributes

NameTypeDefaultDescription
data-marquee-scroll-direction-targetstring""Add to the outermost marquee wrapper to initialise the scroll-direction marquee instance.
data-marquee-direction"left" | "right""left"Sets the default travel direction. "left" moves content left (standard reading direction); "right" moves it right. Direction inverts automatically based on scroll direction.
data-marquee-status"normal" | "inverted""normal"Updated by the script on every scroll update. "normal" means the marquee is travelling in its default direction; "inverted" means it has flipped. Use this in CSS attribute selectors to apply directional styling (e.g. rotating an arrow icon).
data-marquee-speednumber15Controls the base animation duration in seconds per content-width. Lower values are faster. The actual duration is scaled by the content width relative to the viewport width and a breakpoint multiplier.
data-marquee-scroll-speednumber10Controls the parallax offset applied to the scroll container on scroll. Higher values create a more pronounced speed-boost effect as the marquee scrolls into and out of view.
data-marquee-duplicatenumber2Number of additional copies of the collection element to append. Set to at least 1 to fill the full viewport width for a seamless loop. Increase for narrower content items.
data-marquee-collection-targetstring""Add to the element containing all marquee items. The script animates all elements matching this attribute selector as a single group with xPercent.
data-marquee-scroll-targetstring""Add to the scroll container that wraps the collection. The script sets negative margin-left and extra width on this element to create the parallax scroll range, then animates it on a scrubbed ScrollTrigger timeline.

Notes

  • The marquee speed is calculated as: data-marquee-speed × (collectionWidth / windowWidth) × breakpointMultiplier. The breakpoint multiplier is 0.25 on mobile (<479px), 0.5 on tablet (<991px), and 1 on desktop.
  • Direction inversion is driven by ScrollTrigger's self.direction value (1 = scrolling down, -1 = scrolling up). Scrolling down plays the marquee in its default direction; scrolling up inverts it.
  • The parallax scroll effect is a separate scrubbed GSAP timeline that translates the scroll container from a negative to a positive vw value (or vice versa based on direction) as the marquee moves through the viewport.
  • The scroll container's marginLeft and width are widened by scrollSpeedAttr to provide room for the parallax translation without revealing the overflow-hidden boundary.
  • data-marquee-status can be used in CSS to style child elements differently based on travel direction — useful for rotating arrow icons or changing colours.
  • Each marquee instance is fully independent. Multiple instances can coexist on the same page with different directions, speeds, and scroll speeds.

Guide

Required structure

Add [data-marquee-scroll-direction-target] to the outer wrapper, [data-marquee-scroll-target] to the inner scroll container, and [data-marquee-collection-target] to the element holding all items. Place your content inside the collection.

Direction

Set data-marquee-direction="left" or "right" on the wrapper. The marquee starts in this direction and automatically inverts when the user scrolls up.

Speed & duplicates

Use data-marquee-speed to set the base loop duration (lower = faster) and data-marquee-duplicate to control how many extra copies are appended. Start with duplicate="2" and increase if the content is too narrow to fill the viewport.

Scroll parallax speed

data-marquee-scroll-speed sets the vw offset applied as the marquee enters and exits the viewport. Higher values give a more dramatic horizontal shift on scroll. The scroll container's width is expanded automatically to accommodate this range.

CSS directional styling

Use the data-marquee-status and data-marquee-direction attributes together in CSS selectors to style elements differently depending on which way the marquee is currently moving.

/* Arrow rotates based on travel direction */
.marquee__advanced__arrow-svg,
[data-marquee-direction="right"][data-marquee-status="inverted"] .marquee__advanced__arrow-svg {
  transform: rotate(-180deg);
}
[data-marquee-status="inverted"] .marquee__advanced__arrow-svg,
[data-marquee-direction="right"][data-marquee-status="normal"] .marquee__advanced__arrow-svg {
  transform: rotate(-359.999deg);
}

Two marquee layouts

The demo shows two approaches: a text-based marquee where item width is driven by font-size, and an item-width-based marquee using fixed vw dimensions. Both work identically with the script — just swap the content inside [data-marquee-collection-target].