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.
Setup — External Scripts
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/gsap.min.js"></script><script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/ScrollTrigger.min.js"></script>Code
<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>.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);
}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
| Name | Type | Default | Description |
|---|---|---|---|
| data-marquee-scroll-direction-target | string | "" | 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-speed | number | 15 | Controls 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-speed | number | 10 | Controls 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-duplicate | number | 2 | Number 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-target | string | "" | 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-target | string | "" | 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].