Lenis Script Documentation
This script integrates Lenis, a modern smooth-scrolling library, with GSAP (GreenSock Animation Platform) and ScrollTrigger. It provides an ultra-smooth, high-performance scrolling experience while ensuring all scroll-driven animations stay perfectly synchronized. By routing Lenis's requestAnimationFrame (raf) through GSAP's internal ticker and disabling lag smoothing, the setup prevents jitter and maintains precise scroll position tracking across all devices.
Key Features :
Smooth Scrolling: Delivers continuous, fluid scrolling using inertia (lerp: 0.1).
GSAP Synchronization: Keeps ScrollTrigger updates aligned with Lenis's custom scroll frame.
Lag Smoothing Override: Eliminates animation jumps during sudden frame drops or window re-focusing.
<!-- =========================================================
LENIS SMOOTH SCROLL
---------------------------------------------------------
Lenis handles smooth scrolling and is synchronized
with GSAP ScrollTrigger.
========================================================= -->
<script src="https://unpkg.com/lenis@1.3.4/dist/lenis.min.js"></script>
<link rel="stylesheet" href="https://unpkg.com/lenis@1.3.4/dist/lenis.css"/>
<script>
// Initialize Lenis smooth scrolling
const lenis = new Lenis({
smooth: true,
lerp: 0.1,
wheelMultiplier: 0.75,
infinite: false,
});
// Keep ScrollTrigger synchronized with Lenis
lenis.on("scroll", ScrollTrigger.update);
// Run Lenis through the GSAP ticker
gsap.ticker.add((time) => {
lenis.raf(time * 1000);
});
// Disable GSAP lag smoothing for consistent scroll synchronization
gsap.ticker.lagSmoothing(0);
</script>
A. How to Edit Lenis Animations
You can adjust the smooth scroll performance and GSAP integration behavior directly in the script using these key parameters:
Lenis Smooth Scroll Settings:
const lenis = new Lenis({
smooth: true, // Enables/disables smooth scrolling (true/false)
lerp: 0.1, // Scroll interpolation/smoothness (lower values = smoother/slower catch-up)
wheelMultiplier: 0.75, // Mouse wheel scroll speed multiplier
infinite: false, // Enables or disables infinite looping scroll
});
GSAP Ticker & Lag Smoothing:
// Disables GSAP's lag smoothing to ensure GSAP and Lenis tick on the exact same frame
gsap.ticker.lagSmoothing(0);
B. Removing GSAP Animations
If you want to modify or remove the GSAP synchronization while keeping Lenis smooth scroll, follow these steps:
To disable GSAP synchronization entirely while keeping basic Lenis smooth scrolling, remove or comment out the GSAP integration lines:
// Remove or comment out these lines:
// lenis.on("scroll", ScrollTrigger.update);
// gsap.ticker.add((time) => { lenis.raf(time * 1000); });
// gsap.ticker.lagSmoothing(0);
Replace the ticker update with standard requestAnimationFrame logic to keep Lenis running independently:
function raf(time) {
lenis.raf(time);
requestAnimationFrame(raf);
}
requestAnimationFrame(raf);
Block Title Reveal Animation
This script creates a smooth word-by-word reveal animation for titles using GSAP SplitText and ScrollTrigger. Each word is covered by a moving block that reveals the text as it passes across the word.
Key Features :
Word Split: Automatically separates the title into individual words.
Block Reveal: A dark block moves across each word to reveal the text.
Sequential Animation: Words animate one after another with a short stagger.
Scroll Trigger: Animation starts when the title enters the viewport.
One-Time Animation: The animation plays once when the element reaches the trigger position.
Responsive Text: SplitText handles the title as individual words while maintaining the original text structure.
Element Map:
.block-animation-title → Main title element that receives the reveal animation.
.word-inner → Individual words generated by SplitText.
.word-block → Animated block that covers and reveals each word.
<script>
document.addEventListener('DOMContentLoaded', () => {
gsap.registerPlugin(ScrollTrigger, SplitText);
document.querySelectorAll('.block-animation-title').forEach((title) => {
const split = SplitText.create(title, {
type: 'words',
mask: 'words',
wordsClass: 'word-inner',
});
const words = split.words;
words.forEach((word) => {
const maskEl = word.parentElement;
const block = document.createElement('span');
block.className = 'word-block';
maskEl.appendChild(block);
gsap.set(word, { opacity: 0 });
gsap.set(block, { xPercent: -110 });
});
title.classList.add('words-ready');
const tl = gsap.timeline({
scrollTrigger: {
trigger: title,
start: 'top 80%',
},
});
words.forEach((word, i) => {
const block = word.parentElement.querySelector('.word-block');
const offset = i * 0.08;
tl.to(
block,
{
xPercent: 0,
duration: 0.35,
ease: 'power2.inOut',
},
offset
)
.set(
word,
{
opacity: 1,
},
offset + 0.3
)
.to(
block,
{
xPercent: 110,
duration: 0.35,
ease: 'power2.inOut',
},
offset + 0.3
);
});
});
});
</script>
A. How to Edit Block Title Animation
You can customize the visual behavior of the Philosophy section directly in the script using the parameters below.
Animation Duration
//The reveal speed can be adjusted using the duration values.
//Lower value → faster animation
tl.to(block, { xPercent: 0, duration: 0.35, ease: 'power2.inOut' }, offset)
.set(word, { opacity: 1 }, offset + 0.3)
.to(block, { xPercent: 110, duration: 0.35, ease: 'power2.inOut' }, offset + 0.3);
Word Stagger
//The delay between each word is controlled by:
//Lower value → words animate closer together
const offset = i * 0.08;
Reveal Direction
//The block starts from the left:
gsap.set(word, { opacity: 0 });
gsap.set(block, { xPercent: -110 });
//The block then moves across the word:
tl.to(block, { xPercent: 0, duration: 0.35, ease: 'power2.inOut' }, offset)
.set(word, { opacity: 1 }, offset + 0.3)
//And exits toward the right:
.to(block, { xPercent: 110, duration: 0.35, ease: 'power2.inOut' }, offset + 0.3);
Scroll Trigger Position
title = .block-animation-title
trigger: '.block-animation-title', //Class of main trigger
//The animation starts when the title reaches:
const tl = gsap.timeline({
scrollTrigger: {
trigger: title,
start: 'top 80%',
},
});
B. Removing Block Title Animation
If you want to remove the word reveal animation while keeping the title visible, follow these steps:
Remove the line selector
Delete or comment out the entire Block Title Reveal script.
Remove the Animation Class
Remove the: .block-animation-title class from the title element.
Marquee + Drag + Mobile Accordion
This script creates a continuous horizontal marquee with drag interaction, inertia, hover pause, and a responsive accordion interaction for mobile and tablet layouts.
Key Features :
Infinite Marquee: Creates a seamless horizontal looping animation.
Hover Pause: Automatically pauses the marquee when the cursor enters the area.
Drag Interaction: Allows users to manually drag the marquee horizontally.
Inertia: Adds momentum to the marquee after dragging.
Responsive Accordion: Displays additional project information when a project card is clicked on mobile and tablet.
Single Active Card: Automatically closes the previously opened card when another card is selected.
Drag Protection: Prevents accidental project-card clicks after dragging.
Auto Close on Drag: Closes the active accordion when the marquee starts moving.
Responsive: Accordion functionality is only enabled below 991px.
Resize Support: Recalculates the marquee position after the browser is resized.
Element Map:
[wb-data="marquee"] → Main marquee wrapper.
duration → Controls the marquee loop duration.
.collection-item-work → Individual project/card element.
.wrapper-info-work → Hidden information wrapper that expands on click.
.title-name-work → Project title inside the accordion.
.block-info-works → Additional project information.
.horizon-blocker → Visual overlay that moves away when the accordion opens.
.item-horizon-block → Project elements that scale down while dragging.
<script>
gsap.registerPlugin(Draggable, InertiaPlugin);
document.addEventListener('DOMContentLoaded', () => {
const marquee = document.querySelector('[wb-data="marquee"]');
if (!marquee) return;
const duration = parseInt(marquee.getAttribute('duration'), 20) || 5;
const marqueeContent = marquee.firstChild;
if (!marqueeContent) return;
const marqueeContentClone = marqueeContent.cloneNode(true);
marquee.append(marqueeContentClone);
marquee.style.cursor = 'grab';
let tween;
let distanceToTranslate;
let isDragging = false;
let activeTl = null;
const playMarquee = () => {
let progress = tween ? tween.progress() : 0;
if (tween) {
tween.progress(0).kill();
}
const width = parseInt(
getComputedStyle(marqueeContent).getPropertyValue('width'),
10
);
const gap = parseInt(
getComputedStyle(marqueeContent).getPropertyValue('column-gap'),
10
);
distanceToTranslate = -1 * (gap + width);
tween = gsap.fromTo(
marquee.children,
{ x: 0 },
{
x: distanceToTranslate,
duration,
ease: 'none',
repeat: -1,
}
);
tween.progress(progress);
};
playMarquee();
marquee.addEventListener('mouseenter', () => {
if (tween) {
gsap.to(tween, {
timeScale: 0,
duration: 0.3,
});
}
});
marquee.addEventListener('mouseleave', () => {
if (tween) {
gsap.to(tween, {
timeScale: 1,
duration: 0.3,
});
}
});
const proxy = document.createElement('div');
let startProgress = 0;
Draggable.create(proxy, {
type: 'x',
trigger: marquee,
inertia: true,
onPressInit() {
gsap.killTweensOf(tween);
tween.timeScale(0);
marquee.style.cursor = 'grabbing';
startProgress = tween.progress();
gsap.set(proxy, {
x: 0,
});
gsap.to('.item-horizon-block', {
scale: 0.95,
duration: 0.2,
});
},
onDragStart() {
isDragging = true;
if (activeTl) {
activeTl.reverse();
activeTl = null;
}
},
onDrag() {
const progressDelta = this.x / distanceToTranslate;
let newProgress = startProgress + progressDelta;
newProgress = ((newProgress % 1) + 1) % 1;
tween.progress(newProgress);
},
onThrowUpdate() {
const progressDelta = this.x / distanceToTranslate;
let newProgress = startProgress + progressDelta;
newProgress = ((newProgress % 1) + 1) % 1;
tween.progress(newProgress);
},
onRelease() {
marquee.style.cursor = 'grab';
gsap.to('.item-horizon-block', {
scale: 1,
duration: 0.3,
});
requestAnimationFrame(() => {
isDragging = false;
});
},
onThrowComplete() {
const isHovering = marquee.matches(':hover');
gsap.to(tween, {
timeScale: isHovering ? 0 : 1,
duration: 0.3,
});
},
});
function debounce(func) {
let timer;
return function (event) {
if (timer) {
clearTimeout(timer);
}
timer = setTimeout(() => {
func();
}, 500);
};
}
window.addEventListener(
'resize',
debounce(playMarquee)
);
if (!window.matchMedia('(max-width: 991px)').matches) {
return;
}
const items = document.querySelectorAll(
'.collection-item-work'
);
items.forEach((item) => {
const wrapper = item.querySelector(
'.wrapper-info-work'
);
if (!wrapper) return;
const title = wrapper.querySelector(
'.title-name-work'
);
const block = wrapper.querySelector(
'.block-info-works'
);
const horizon = item.querySelector(
'.horizon-blocker'
);
if (!title || !block || !horizon) return;
const tl = gsap.timeline({
paused: true,
});
tl.fromTo(
wrapper,
{ height: '0%' },
{
height: '100%',
duration: 0.5,
ease: 'power1.out',
},
0
);
tl.fromTo(
[title, block],
{
opacity: 0,
y: '3vw',
},
{
opacity: 1,
y: '0vw',
duration: 0.75,
ease: 'power1.out',
},
0
);
tl.to(
horizon,
{
y: '100%',
duration: 0.5,
ease: 'power1.out',
},
0
);
item.addEventListener('click', (e) => {
if (isDragging) {
e.preventDefault();
return;
}
const isAlreadyActive = activeTl === tl;
if (isAlreadyActive) {
return;
}
e.preventDefault();
if (activeTl) {
activeTl.reverse();
}
tl.play(0);
activeTl = tl;
});
});
});
</script>
A. How to Edit Marquee Animation
Marquee Speed
//The marquee speed is controlled by the duration attribute:
<div wb-data="marquee" duration="5">
//Lower value → faster marquee
Hover Pause
The marquee automatically pauses when the cursor enters the marquee area.
This behavior is controlled by:
timeScale: 0
Drag Scale
The project elements slightly scale down while the marquee is being dragged:
scale: 0.95
Accordion Animation Speed
The accordion opening animation uses:
tl.fromTo(wrapper, { height: '0%' }, { height: '100%', duration: 0.5, ease: 'power1.out' }, 0);
while the content reveal uses:
tl.fromTo([title, block], { opacity: 0, y: '3vw' }, { opacity: 1, y: '0vw', duration: 0.75, ease: 'power1.out' }, 0);
Increasing these values makes the animation slower.
B. How to Edit the Marquee Content
The marquee uses the first child inside:
[wb-data="marquee"]
as its template.
The script automatically creates a duplicate:
const marqueeContentClone = marqueeContent.cloneNode(true);
This duplicate allows the marquee to loop continuously without a visible gap.You only need to edit the original marquee content in Webflow. The second copy is generated automatically by the script.
C. How to Remove the Mobile Accordion
If you want to keep the marquee and drag interaction but remove the mobile accordion:
Remove the Accordion Section
Delete the code starting from:
if (!window.matchMedia('(max-width: 991px)').matches) return;
Remove the Animation Class
Remove the: .block-animation-title class from the title element.
D. How to Remove the Entire Marquee Animation
If you want to remove the entire interaction:
Remove the custom script
Remove these attributes/classes if no longer used:
[wb-data="marquee"] duration .item-horizon-block
Restore the marquee content
Because the script automatically creates a duplicate element, keep only the original marquee content in Webflow.