On the planet of scroll-driven animations, discovering recent methods to current content material whereas sustaining readability is an ongoing problem. Immediately, we’ll construct an interactive dual-column textual content animation that creates a mesmerizing wave impact as customers scroll, with a synchronized picture that adapts to the content material in focus
Our animation options:
- Two columns of textual content shifting in reverse wave patterns
- Easy, physics-based transitions utilizing GSAP’s
quickTo - A centered picture that updates based mostly on the targeted textual content
- Totally configurable wave parameters (velocity, frequency)
- Responsive habits that adapts to totally different display screen sizes
We’ll construct with Vite and energy our animations with GSAP (ScrollTrigger and ScrollSmoother).
The Idea
This animation concept got here to me after recreating an animation from the fifteenth Anniversary Plus Ex website. I actually beloved the sensation and sensitivity the textual content had when scrolling by way of the web page, it intrigued me fairly a bit. After some extra concept iterations, the idea of this twin wave animation got here to life as a approach to current an inventory of components in a different way.
Setting Up the HTML Construction
Let’s begin with the markup. Our construction wants ScrollSmoother wrappers, two columns, a centered picture container, and knowledge attributes for configuration.
To realize a consequence just like the demo, I like to recommend utilizing a considerable variety of components. When you’re free to adapt this tutorial for smaller lists, take note you’ll want to regulate the CSS accordingly to take care of a visually pleasing consequence.
<!-- ScrollSmoother wrapper (required for {smooth} scroll) -->
<essential id="smooth-wrapper" class="container">
<div id="smooth-content">
<!-- Spacer for scroll distance -->
<div class="spacer"></div>
<div
class="dual-wave-wrapper"
data-animation="dual-wave"
data-wave-number="12"
data-wave-speed="1"
>
<!-- Left column with product names and picture references -->
<div class="wave-column wave-column-left">
<div class="animated-text" data-image="tesla.webp">Volt R2</div>
<!-- Tesla -->
<div class="animated-text" data-image="chanel.webp">Éclat</div>
<!-- Chanel -->
<div class="animated-text" data-image="apple.webp">Venture Ion</div>
<!-- Apple -->
<div class="animated-text" data-image="BMW.webp">AeroLine</div>
<!-- BMW -->
<div class="animated-text" data-image="YSL.webp">Série Noir</div>
<!-- Saint Laurent -->
<div class="animated-text" data-image="nike.webp">UltraRun</div>
<!-- Nike -->
<div class="animated-text" data-image="hermes.webp">Atelier 03</div>
<!-- Hermès -->
<div class="animated-text" data-image="adidas.webp">Pulse One</div>
<!-- Adidas -->
<div class="animated-text" data-image="prada.webp">Linea 24</div>
<!-- Prada -->
<div class="animated-text" data-image="google.webp">
Echo Collection
</div>
<!-- Google -->
<div class="animated-text" data-image="polestar.webp">Zero</div>
<!-- Polestar -->
<div class="animated-text" data-image="balenciaga.webp">
Shift/Black
</div>
<!-- Balenciaga -->
<div class="animated-text" data-image="audi.webp">Photo voltaic Drift</div>
<!-- Audi -->
<div class="animated-text" data-image="valentino.webp">Nº 27</div>
<!-- Valentino -->
<div class="animated-text" data-image="samsung.webp">Mode/3</div>
<!-- Samsung -->
<div class="animated-text" data-image="bottega.webp">Pure Type</div>
<!-- Bottega Veneta -->
</div>
<!-- Centered picture thumbnail -->
<div class="image-thumbnail-wrapper">
<img src="1.webp" alt="Marketing campaign Picture" class="image-thumbnail" />
</div>
<!-- Proper column with model names -->
<div class="wave-column wave-column-right">
<div class="animated-text">Tesla</div>
<div class="animated-text">Chanel</div>
<div class="animated-text">Apple</div>
<div class="animated-text">BMW</div>
<div class="animated-text">Saint Laurent</div>
<div class="animated-text">Nike</div>
<div class="animated-text">Hermès</div>
<div class="animated-text">Adidas</div>
<div class="animated-text">Prada</div>
<div class="animated-text">Google</div>
<div class="animated-text">Polestar</div>
<div class="animated-text">Balenciaga</div>
<div class="animated-text">Audi</div>
<div class="animated-text">Valentino</div>
<div class="animated-text">Samsung</div>
<div class="animated-text">Bottega Veneta</div>
</div>
</div>
<div class="spacer"></div>
</div>
</essential>
Key structural choices:
smooth-wrapper&smooth-content: Required for ScrollSmoother to work—wraps all scrollable content material.spacer: Creates vertical scroll distance, permitting the animation to play over prolonged scrollingdata-wave-number: Controls wave frequency (what number of wave cycles happen throughout all components)data-wave-speed: Controls animation velocity throughout scrolldata-image: Solely on left column components—centralizes picture supply administration- Separate columns: Permits impartial wave route management
The HTML is deliberately easy. All of the magic occurs in JavaScript, maintaining markup clear and maintainable.
Styling the Basis
Our CSS establishes the visible format and units up {smooth} transitions for the targeted state. We begin with the ScrollSmoother construction.
/* Spacer creates vertical scroll distance */
.spacer {
top: 75svh; /* Modify based mostly on desired scroll size */
}
/* Essential animation wrapper */
.dual-wave-wrapper {
show: flex;
width: 100%;
place: relative;
hole: 25vw; /* Creates respiratory room for the middle picture */
}
.wave-column {
flex: 1;
show: flex;
flex-direction: column;
hole: 1.25rem;
font-size: clamp(2rem, 10vw, 3rem);
font-weight: 400;
line-height: 0.7;
place: relative;
z-index: 100; /* Ensures textual content stays above picture */
}
.wave-column-left {
align-items: flex-start;
}
.wave-column-right {
align-items: flex-end;
}
.animated-text {
width: max-content;
colour: #4d4d4d;
text-transform: uppercase;
transition: colour 300ms ease-out;
}
.animated-text.targeted {
colour: white;
z-index: 2;
}
Why these selections?
- Flexbox format: Supplies equal column widths whereas sustaining flexibility
clamp()for font-size: Creates responsive typography with out media queries for the bottom measurementmax-contentwidth: Prevents textual content from wrapping, maintaining the wave impact clear- CSS transition on colour: Enhances GSAP’s place animations with state adjustments
Centering the Picture
The picture thumbnail wants particular positioning to remain centered between columns:
.image-thumbnail-wrapper {
place: absolute;
high: 0;
left: 50%;
rework: translate(-50%, 0);
width: 15vw;
top: auto;
z-index: 1; /* Under textual content, above background */
pointer-events: none;
show: grid;
place-items: middle;
}
.image-thumbnail {
width: auto;
top: auto;
max-width: 100%;
max-height: 30vh;
}
Design concerns:
pointer-events: none: Picture received’t intrude with scroll interactions- Viewport-relative sizing: 15vw ensures constant scaling throughout screens
- Max-height constraint: Prevents outsized photographs on brief viewports
Responsive Changes
@media (max-width: 1023px) {
.dual-wave-wrapper {
hole: 10vw;
}
.wave-column {
hole: 2.5rem;
font-size: 5vw;
}
.image-thumbnail-wrapper {
width: 50vw;
}
}
On smaller screens, we tighten the hole and improve the picture measurement proportionally to take care of visible stability.
Setting Up Easy Scrolling
Earlier than diving into the animation class, we have to arrange {smooth} scrolling. That is important for creating fluid scroll-driven animations. With out it, the wave impact will really feel jerky and disconnected.
Don’t overlook to put in GSAP utilizing your most popular bundle supervisor or through the GSAP CDN, relying in your setup.
npm set up gsap
or
<script src="https://cdn.jsdelivr.web/npm/gsap@3.14.1/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.web/npm/gsap@3.14.1/dist/ScrollTrigger.min.js"></script>
<!-- ScrollSmoother requires ScrollTrigger -->
<script src="https://cdn.jsdelivr.web/npm/gsap@3.14.1/dist/ScrollSmoother.min.js"></script>
Configuring ScrollSmoother
In your essential.js file, initialize ScrollSmoother earlier than creating any animations:
// mains.js
import gsap from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
import { ScrollSmoother } from 'gsap/ScrollSmoother';
// Register GSAP plugins
gsap.registerPlugin(ScrollTrigger, ScrollSmoother);
// Create {smooth} scroll occasion
ScrollSmoother.create({
{smooth}: 1.5, // Smoothing depth
normalizeScroll: true, // Normalize scroll habits throughout browsers
});
Why these settings?
{smooth}: 1.5: Supplies a balanced smoothing impact, not too sluggish, not too fastnormalizeScroll: true: Ensures constant scroll habits throughout all browsers and units
Various: Utilizing Lenis
In the event you choose or your challenge already makes use of Lenis for {smooth} scrolling, you should use it as a substitute of ScrollSmoother:
// essential.js
import Lenis from "lenis";
const lenis = new Lenis({
length: 1.5,
});
// Synchronize Lenis scrolling with GSAP's ScrollTrigger plugin
lenis.on("scroll", ScrollTrigger.replace);
// Add Lenis's requestAnimationFrame (raf) methodology to GSAP's ticker
// This ensures Lenis's {smooth} scroll animation updates on every GSAP tick
gsap.ticker.add((time) => {
lenis.raf(time * 1000); // Convert time from seconds to milliseconds
});
// Disable lag smoothing in GSAP to stop any delay in scroll animations
gsap.ticker.lagSmoothing(0);
Each libraries work seamlessly with GSAP’s ScrollTrigger. Select the choice that most closely fits your challenge.
Constructing the Animation Class
Now for the fascinating half: the JavaScript that brings all the things to life. We’ll construct this progressively, beginning with the category constructor and initialization.
Class Setup and Configuration
import gsap from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
export class DualWaveAnimation {
constructor(wrapper, choices = {}) {
this.wrapper = wrapper instanceof Factor
? wrapper
: doc.querySelector(wrapper);
// Learn configuration from knowledge attributes
const waveNumber = this.wrapper?.dataset.waveNumber
? parseFloat(this.wrapper.dataset.waveNumber)
: 2;
const waveSpeed = this.wrapper?.dataset.waveSpeed
? parseFloat(this.wrapper.dataset.waveSpeed)
: 1;
this.config = {
waveNumber,
waveSpeed,
...choices, // Enable programmatic overrides
};
this.currentImage = null; // Observe present picture to stop pointless updates
}
init() {
if (!this.wrapper) {
console.warn('Wrapper not discovered');
return;
}
this.leftColumn = this.wrapper.querySelector('.wave-column-left');
this.rightColumn = this.wrapper.querySelector('.wave-column-right');
if (!this.leftColumn || !this.rightColumn) {
console.warn('Columns not discovered');
return;
}
this.setupAnimation();
}
}
Why this construction?
- Versatile constructor: Accepts both a DOM component or a CSS selector
- Knowledge attribute parsing: Permits HTML-based configuration with out touching JavaScript
- Choices merging: Supplies programmatic override functionality
- Defensive coding: Early returns stop errors if components are lacking
Setting Up the Wave Animation
The setupAnimation methodology orchestrates all of the shifting components:
setupAnimation() {
// Accumulate all textual content components from each columns
this.leftTexts = gsap.utils.toArray(
this.leftColumn.querySelectorAll('.animated-text')
);
this.rightTexts = gsap.utils.toArray(
this.rightColumn.querySelectorAll('.animated-text')
);
this.thumbnail = this.wrapper.querySelector('.image-thumbnail');
if (this.leftTexts.size === 0 || this.rightTexts.size === 0) return;
// Create fast setters for {smooth} textual content animations
this.leftQuickSetters = this.leftTexts.map((textual content) =>
gsap.quickTo(textual content, 'x', { length: 0.6, ease: 'power4.out' })
);
this.rightQuickSetters = this.rightTexts.map((textual content) =>
gsap.quickTo(textual content, 'x', { length: 0.6, ease: 'power4.out' })
);
// Calculate preliminary ranges and positions
this.calculateRanges();
this.setInitialPositions(this.leftTexts, this.leftRange, 1);
this.setInitialPositions(this.rightTexts, this.rightRange, -1);
// Setup scroll set off
this.setupScrollTrigger();
// Recalculate ranges on window resize
this.resizeHandler = () => {
this.calculateRanges();
};
window.addEventListener('resize', this.resizeHandler);
}
Understanding quickTo:
GSAP’s quickTo is a efficiency optimization that pre-creates animation features. As a substitute of calling gsap.to() repeatedly (which creates new tweens), quickTo returns a operate that updates an current tween. That is essential for scroll-driven animations the place we’re updating positions 60 instances per second.
The multiplier system:
Discover the 1 and -1 multipliers handed to setInitialPositions. This creates the opposing wave movement:
- Left column:
1= constructive X values (strikes proper) - Proper column:
-1= detrimental X values (strikes left)
Calculating Motion Ranges
Every textual content component wants to maneuver horizontally inside a protected vary that forestalls it from:
- Overlapping with the middle picture
- Shifting outdoors its column boundaries
calculateRanges() {
// Calculate ranges based mostly on column widths minus max component width
const maxLeftTextWidth = Math.max(
...this.leftTexts.map((t) => t.offsetWidth)
);
const maxRightTextWidth = Math.max(
...this.rightTexts.map((t) => t.offsetWidth)
);
this.leftRange = {
minX: 0,
maxX: this.leftColumn.offsetWidth - maxLeftTextWidth,
};
this.rightRange = {
minX: 0,
maxX: this.rightColumn.offsetWidth - maxRightTextWidth,
};
}
Why discover the widest component?
Textual content components differ in width (“Tesla” vs “Mercedes-Benz”). By discovering the utmost width, we guarantee even the widest component stays totally seen when positioned on the extremes.
This methodology known as on initialization and every time the window resizes, guaranteeing the animation adapts to viewport adjustments.
Setting Preliminary Positions
Earlier than any scrolling occurs, we have to place every textual content component in line with its place within the wave cycle:
setInitialPositions(texts, vary, multiplier) {
const rangeSize = vary.maxX - vary.minX;
texts.forEach((textual content, index) => {
// Calculate preliminary part for this component
const initialPhase = this.config.waveNumber * index - Math.PI / 2;
// Convert sine wave (-1 to 1) to progress (0 to 1)
const initialWave = Math.sin(initialPhase);
const initialProgress = (initialWave + 1) / 2;
// Map progress to pixel place inside vary
const startX = (vary.minX + initialProgress * rangeSize) * multiplier;
gsap.set(textual content, { x: startX });
});
}
Breaking down the maths:
- Part calculation:
waveNumber * index - Math.PI / 2waveNumber * index: Distributes components alongside the wave cycleMath.PI / 2: Offsets the start line (begins wave at midpoint)
- Sine to progress:
(Math.sin(part) + 1) / 2Math.sin()returns values from -1 to 1- Including 1 provides us 0 to 2
- Dividing by 2 normalizes to 0 to 1
- Mapping to pixels:
minX + progress * rangeSize- Converts normalized progress to precise pixel values
- Multiplied by
multiplierfor directional management
Scroll Set off Setup
We use GSAP’s ScrollTrigger to hear for scroll occasions and set off our replace operate:
setupScrollTrigger() {
this.scrollTrigger = ScrollTrigger.create({
set off: this.wrapper,
begin: 'high backside', // When high of wrapper hits backside of viewport
finish: 'backside high', // When backside of wrapper hits high of viewport
onUpdate: (self) => this.handleScroll(self),
});
}
Why these set off factors?
begin: 'high backside': Animation begins as quickly because the wrapper enters the viewportfinish: 'backside high': Animation continues till the wrapper totally exitsonUpdate: Enable to make use of the progress that known as repeatedly as scroll progress adjustments
This provides us a {smooth}, steady animation all through all the scroll vary.
Dealing with Scroll Updates
The handleScroll methodology is the guts of our animation. It runs on each scroll replace and orchestrates all visible adjustments:
handleScroll(self) {
const globalProgress = self.progress;
// Since left and proper texts are all the time aligned, we solely must examine one column
const closestIndex = this.findClosestToViewportCenter();
// Replace each columns with their respective multipliers
this.updateColumn(
this.leftTexts,
this.leftQuickSetters,
this.leftRange,
globalProgress,
closestIndex,
1
);
this.updateColumn(
this.rightTexts,
this.rightQuickSetters,
this.rightRange,
globalProgress,
closestIndex,
-1
);
// Get the targeted textual content component for thumbnail replace
const focusedText = this.leftTexts[closestIndex];
this.updateThumbnail(this.thumbnail, focusedText);
}
The coordination technique:
- Calculate scroll progress: One quantity (0-1) drives all the animation
- Discover the targeted component: Verify solely the left column since each columns are all the time horizontally aligned
- Replace positions: Transfer all textual content components in each columns in line with wave components utilizing the identical index
- Replace states: Add “targeted” class to centered components in each columns
- Sync picture: Replace thumbnail based mostly on the targeted textual content component
Updating Column Positions
This methodology updates all textual content components in a column whereas highlighting the targeted one:
updateColumn(texts, setters, vary, progress, focusedIndex, multiplier) {
const rangeSize = vary.maxX - vary.minX;
texts.forEach((textual content, index) => {
// Calculate wave place for this component at present scroll progress
const finalX = this.calculateWavePosition(
index,
progress,
vary.minX,
rangeSize
) * multiplier;
// Use quickTo setter for {smooth} animation
setters[index](finalX);
// Toggle targeted state
if (index === focusedIndex) {
textual content.classList.add('targeted');
} else {
textual content.classList.take away('targeted');
}
});
}
Efficiency be aware:
By separating the calculation (calculateWavePosition) from the appliance (setters[index]()), we preserve the code clear and maintainable. The quickTo setters deal with the animation easily with out creating new tweens.
The Wave Place Components
That is the place the mathematical magic occurs:
calculateWavePosition(index, globalProgress, minX, vary) {
// Calculate part: combines component index with scroll progress
const part =
this.config.waveNumber * index +
this.config.waveSpeed * globalProgress * Math.PI * 2 -
Math.PI / 2;
// Calculate wave worth (-1 to 1)
const wave = Math.sin(part);
// Convert to progress (0 to 1)
const cycleProgress = (wave + 1) / 2;
// Map to pixel vary
return minX + cycleProgress * vary;
}
Understanding the part calculation:
part = waveNumber × index + waveSpeed × progress × 2π - π/2
└─────┬─────┘ └──────────┬──────────┘ └─┬─┘
│ │ │
Wave frequency Scroll-driven Beginning
distribution offset offset
waveNumber * index: Areas components alongside the wave (greater = extra waves)waveSpeed * globalProgress * Math.PI * 2: Animates the wave based mostly on scrollMath.PI / 2: Shifts the wave to start out at its midpoint
Discovering the Closest Factor
To find out which textual content must be “targeted,” we measure every component’s distance from the viewport middle. Since each columns are all the time horizontally aligned, we solely must examine one column:
findClosestToViewportCenter() {
const viewportCenter = window.innerHeight / 2;
let closestIndex = 0;
let minDistance = Infinity;
// Solely examine left column since left and proper are all the time horizontally aligned
this.leftTexts.forEach((textual content, index) => {
const rect = textual content.getBoundingClientRect();
const elementCenter = rect.high + rect.top / 2;
const distance = Math.abs(elementCenter - viewportCenter);
if (distance < minDistance) {
minDistance = distance;
closestIndex = index;
}
});
return closestIndex;
}
Updating the Thumbnail
The picture replace logic handles each supply adjustments and vertical positioning:
updateThumbnail(thumbnail, focusedText) {
if (!thumbnail || !focusedText) return;
// Get picture from left column (single supply of fact)
let newImage = focusedText.dataset.picture;
// If targeted textual content has no picture, search for similar index in left column
if (!newImage) {
const focusedIndex = this.rightTexts.indexOf(focusedText);
if (focusedIndex !== -1 && this.leftTexts[focusedIndex]) {
newImage = this.leftTexts[focusedIndex].dataset.picture;
}
}
// Solely change picture if totally different (prevents flicker)
if (newImage && this.currentImage !== newImage) {
this.currentImage = newImage;
thumbnail.src = newImage;
}
// Place thumbnail to remain centered in viewport
const wrapperRect = this.wrapper.getBoundingClientRect();
const viewportCenter = window.innerHeight / 2;
const thumbnailHeight = thumbnail.offsetHeight;
const wrapperHeight = this.wrapper.offsetHeight;
// Calculate excellent Y place (centered in viewport)
const idealY = viewportCenter - wrapperRect.high - thumbnailHeight / 2;
// Clamp to permit picture to overflow wrapper to middle on first/final textual content
const minY = -thumbnailHeight / 2;
const maxY = wrapperHeight - thumbnailHeight / 2;
const clampedY = Math.max(minY, Math.min(maxY, idealY));
// Apply place instantly with out animation for good scroll sync
gsap.set(thumbnail, { y: clampedY });
}
Key design choices:
- Single supply technique: Left column holds all picture knowledge. Proper column references by index.
- Change detection:
currentImagemonitoring prevents pointless DOM updates - Viewport-relative positioning: Picture stays centered as you scroll
- Overflow allowance:
minYandmaxYlet the picture prolong past wrapper bounds, guaranteeing it could possibly middle on the primary and final textual content components gsap.setvsgsap.to: Prompt updates preserve tight scroll synchronization
Why permit overflow?
With out the overflow allowance, the picture would “stick” to the wrapper’s high/backside edges when scrolling to the very first or final textual content component. The detrimental minY and prolonged maxY let it transfer freely to take care of good centering.
Cleanup and Reminiscence Administration
A correct cleanup methodology prevents reminiscence leaks when destroying situations:
destroy() {
if (this.scrollTrigger) {
this.scrollTrigger.kill();
}
if (this.resizeHandler) {
window.removeEventListener('resize', this.resizeHandler);
}
}
All the time name this in the event you’re dynamically creating/destroying animation situations (for instance, in single-page functions).
Utilizing the Animation
With our class full, right here’s an entire initialization instance in your essential.js:
import gsap from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
import { ScrollSmoother } from 'gsap/ScrollSmoother';
import { DualWaveAnimation } from './dual-wave/DualWaveAnimation.js';
// Register GSAP plugins
gsap.registerPlugin(ScrollTrigger, ScrollSmoother);
// Create {smooth} scroll occasion (ESSENTIAL for fluid animation)
ScrollSmoother.create({
{smooth}: 1.5,
results: true,
normalizeScroll: true,
});
// Initialize with default settings from knowledge attributes
const animation = new DualWaveAnimation('.dual-wave-wrapper');
animation.init();
// Or with customized choices that override knowledge attributes
const customAnimation = new DualWaveAnimation('.dual-wave-wrapper', {
waveNumber: 8,
waveSpeed: 0.5,
});
customAnimation.init();
Bear in mind: ScrollSmoother (or another like Lenis) is essential. With out {smooth} scrolling, the wave animation will seem jerky and disconnected from the scroll enter.
Experimenting with Wave Settings
Now that all the things is about up and we will simply tweak the wave parameters, listed below are a number of examples of what you will get by altering the variety of waves. Be at liberty to check issues out by yourself, be curious, strive adjusting the format, the variety of waves, and so forth.
I hope this tutorial was helpful and that you just loved it. In the event you’d wish to see extra of my work, make sure that to observe me in your favourite platforms: X, LinkedIn, Instagram, and YouTube. I share all my tasks, experiments, and recreations of award-winning web sites there.
Thanks for studying!


