Hey! Jorge Toloza once more, Co-Founder and Artistic Director at DDS Studio. On this tutorial, we’re going to construct a visually wealthy, infinitely scrolling grid the place photos transfer with a parallax impact primarily based on scroll and drag interactions.
We’ll use GSAP for buttery-smooth animations, add a sprinkle of math to realize infinite tiling, and convey all of it along with dynamic visibility animations and a staggered intro reveal.
Let’s get began!
Setting Up the HTML Container
To begin, we solely want a single container to carry all of the tiled picture components. Since we’ll be producing and positioning every tile dynamically with JavaScript, there’s no want for any static markup inside. This retains our HTML clear and scalable as we duplicate tiles for infinite scrolling.
Primary Styling for the Grid Gadgets
Now that we've our container, let’s give it the foundational types it wants to carry and animate a big set of tiles.
We’ll use absolute
positioning for every tile so we are able to freely place them anyplace within the grid. The outer container (#photos
) is ready to relative
so that every one youngster .merchandise
components are positioned appropriately inside it. Every picture fills its tile, and we’ll use will-change: remodel
to optimize animation efficiency.
#photos {
width: 100%;
peak: 100%;
show: inline-block;
white-space: nowrap;
place: relative;
.merchandise {
place: absolute;
prime: 0;
left: 0;
will-change: remodel;
white-space: regular;
.item-wrapper {
will-change: remodel;
}
.item-image {
overflow: hidden;
img {
width: 100%;
peak: 100%;
object-fit: cowl;
will-change: remodel;
}
}
small {
width: 100%;
show: block;
font-size: 8rem;
line-height: 1.25;
margin-top: 12rem;
}
}
}
Defining Merchandise Positions with JSON from Figma
To regulate the visible format of our grid, we’ll use design knowledge exported instantly from Figma. This offers us pixel-perfect placement whereas preserving format logic separate from our code.
I created a fast format in Figma utilizing rectangles to characterize tile positions and dimensions. Then I exported that knowledge right into a JSON file, giving us a easy array of objects containing x
, y
, w
, and h
values for every tile.

[
{x: 71, y: 58, w: 400, h: 270},
{x: 211, y: 255, w: 540, h: 360},
{x: 631, y: 158, w: 400, h: 270},
{x: 1191, y: 245, w: 260, h: 195},
{x: 351, y: 687, w: 260, h: 290},
{x: 751, y: 824, w: 205, h: 154},
{x: 911, y: 540, w: 260, h: 350},
{x: 1051, y: 803, w: 400, h: 300},
{x: 71, y: 922, w: 350, h: 260},
]
Producing an Infinite Grid with JavaScript
With the format knowledge outlined, the subsequent step is to dynamically generate our tile grid within the DOM and allow it to scroll infinitely in each instructions.
This includes three predominant steps:
- Compute the scaled tile dimensions primarily based on the viewport and the unique Figma format’s side ratio.
- Duplicate the grid in each the X and Y axes in order that as one tile set strikes out of view, one other seamlessly takes its place.
- Retailer metadata for every tile, equivalent to its unique place and a random easing worth, which we’ll use to fluctuate the parallax animation barely for a extra natural impact.
The infinite scroll phantasm is achieved by duplicating your entire tile set horizontally and vertically. This 2×2 tiling method ensures there’s all the time a full set of tiles prepared to slip into view because the person scrolls or drags.
onResize() {
// Get present viewport dimensions
this.winW = window.innerWidth;
this.winH = window.innerHeight;
// Scale tile dimension to match viewport width whereas preserving unique side ratio
this.tileSize = {
w: this.winW,
h: this.winW * (this.originalSize.h / this.originalSize.w),
};
// Reset scroll state
this.scroll.present = { x: 0, y: 0 };
this.scroll.goal = { x: 0, y: 0 };
this.scroll.final = { x: 0, y: 0 };
// Clear present tiles from container
this.$container.innerHTML = '';
// Scale merchandise positions and sizes primarily based on new tile dimension
const baseItems = this.knowledge.map((d, i) => {
const scaleX = this.tileSize.w / this.originalSize.w;
const scaleY = this.tileSize.h / this.originalSize.h;
const supply = this.sources[i % this.sources.length];
return {
src: supply.src,
caption: supply.caption,
x: d.x * scaleX,
y: d.y * scaleY,
w: d.w * scaleX,
h: d.h * scaleY,
};
});
this.gadgets = [];
// Offsets to duplicate the grid in X and Y for seamless looping (2x2 tiling)
const repsX = [0, this.tileSize.w];
const repsY = [0, this.tileSize.h];
baseItems.forEach((base) => {
repsX.forEach((offsetX) => {
repsY.forEach((offsetY) => {
// Create merchandise DOM construction
const el = doc.createElement('div');
el.classList.add('merchandise');
el.fashion.width = `${base.w}px`;
const wrapper = doc.createElement('div');
wrapper.classList.add('item-wrapper');
el.appendChild(wrapper);
const itemImage = doc.createElement('div');
itemImage.classList.add('item-image');
itemImage.fashion.width = `${base.w}px`;
itemImage.fashion.peak = `${base.h}px`;
wrapper.appendChild(itemImage);
const img = new Picture();
img.src = `./img/${base.src}`;
itemImage.appendChild(img);
const caption = doc.createElement('small');
caption.innerHTML = base.caption;
// Cut up caption into strains for staggered animation
const break up = new SplitText(caption, {
sort: 'strains',
masks: 'strains',
linesClass: 'line'
});
break up.strains.forEach((line, i) => {
line.fashion.transitionDelay = `${i * 0.15}s`;
line.parentElement.fashion.transitionDelay = `${i * 0.15}s`;
});
wrapper.appendChild(caption);
this.$container.appendChild(el);
// Observe caption visibility for animation triggering
this.observer.observe(caption);
// Retailer merchandise metadata together with offset, easing, and bounding field
this.gadgets.push({
el,
container: itemImage,
wrapper,
img,
x: base.x + offsetX,
y: base.y + offsetY,
w: base.w,
h: base.h,
extraX: 0,
extraY: 0,
rect: el.getBoundingClientRect(),
ease: Math.random() * 0.5 + 0.5, // Random parallax easing for natural motion
});
});
});
});
// Double the tile space to account for 2x2 duplication
this.tileSize.w *= 2;
this.tileSize.h *= 2;
// Set preliminary scroll place barely off-center for visible steadiness
this.scroll.present.x = this.scroll.goal.x = this.scroll.final.x = -this.winW * 0.1;
this.scroll.present.y = this.scroll.goal.y = this.scroll.final.y = -this.winH * 0.1;
}
Key Ideas
- Scaling the format ensures that your Figma-defined design adapts to any display screen dimension with out distortion.
- 2×2 duplication ensures seamless continuity when the person scrolls in any route.
- Random easing values create slight variation in tile motion, making the parallax impact really feel extra pure.
extraX
andextraY
values will later be used to shift tiles again into view as soon as they scroll offscreen.- SplitText animation is used to interrupt every caption (
) into particular person strains, enabling line-by-line animation.
Including Interactive Scroll and Drag Occasions
To convey the infinite grid to life, we have to join it to person enter. This contains:
- Scrolling with the mouse wheel or trackpad
- Dragging with a pointer (mouse or contact)
- Clean movement between enter updates utilizing linear interpolation (lerp)
Somewhat than immediately snapping to new positions, we interpolate between the present and goal scroll values, which creates fluid, pure transitions.
Scroll and Drag Monitoring
We seize two forms of person interplay:
1) Wheel Occasions
Wheel enter updates a goal scroll place. We multiply the deltas by a damping issue to regulate sensitivity.
onWheel(e) {
e.preventDefault();
const issue = 0.4;
this.scroll.goal.x -= e.deltaX * issue;
this.scroll.goal.y -= e.deltaY * issue;
}
2) Pointer Dragging
On mouse or contact enter, we observe when the drag begins, then replace scroll targets primarily based on the pointer’s motion.
onMouseDown(e) {
e.preventDefault();
this.isDragging = true;
doc.documentElement.classList.add('dragging');
this.mouse.press.t = 1;
this.drag.startX = e.clientX;
this.drag.startY = e.clientY;
this.drag.scrollX = this.scroll.goal.x;
this.drag.scrollY = this.scroll.goal.y;
}
onMouseUp() {
this.isDragging = false;
doc.documentElement.classList.take away('dragging');
this.mouse.press.t = 0;
}
onMouseMove(e) {
this.mouse.x.t = e.clientX / this.winW;
this.mouse.y.t = e.clientY / this.winH;
if (this.isDragging) {
const dx = e.clientX - this.drag.startX;
const dy = e.clientY - this.drag.startY;
this.scroll.goal.x = this.drag.scrollX + dx;
this.scroll.goal.y = this.drag.scrollY + dy;
}
}
Smoothing Movement with Lerp
Within the render loop, we interpolate between the present and goal scroll values utilizing a lerp perform. This creates {smooth}, decaying movement moderately than abrupt adjustments.
render() {
// Clean present → goal
this.scroll.present.x += (this.scroll.goal.x - this.scroll.present.x) * this.scroll.ease;
this.scroll.present.y += (this.scroll.goal.y - this.scroll.present.y) * this.scroll.ease;
// Calculate delta for parallax
const dx = this.scroll.present.x - this.scroll.final.x;
const dy = this.scroll.present.y - this.scroll.final.y;
// Replace every tile
this.gadgets.forEach(merchandise => {
const parX = 5 * dx * merchandise.ease + (this.mouse.x.c - 0.5) * merchandise.rect.width * 0.6;
const parY = 5 * dy * merchandise.ease + (this.mouse.y.c - 0.5) * merchandise.rect.peak * 0.6;
// Infinite wrapping
const posX = merchandise.x + this.scroll.present.x + merchandise.extraX + parX;
if (posX > this.winW) merchandise.extraX -= this.tileSize.w;
if (posX + merchandise.rect.width < 0) merchandise.extraX += this.tileSize.w;
const posY = merchandise.y + this.scroll.present.y + merchandise.extraY + parY;
if (posY > this.winH) merchandise.extraY -= this.tileSize.h;
if (posY + merchandise.rect.peak < 0) merchandise.extraY += this.tileSize.h;
merchandise.el.fashion.remodel = `translate(${posX}px, ${posY}px)`;
});
this.scroll.final.x = this.scroll.present.x;
this.scroll.final.y = this.scroll.present.y;
requestAnimationFrame(this.render);
}
The scroll.ease
worth controls how briskly the scroll place catches as much as the goal—smaller values lead to slower, smoother movement.
Animating Merchandise Visibility with IntersectionObserver
To boost the visible hierarchy and focus, we’ll spotlight solely the tiles which are at present throughout the viewport. This creates a dynamic impact the place captions seem and styling adjustments as tiles enter view.
We’ll use the IntersectionObserver API to detect when every tile turns into seen and toggle a CSS class accordingly.
this.observer = new IntersectionObserver(entries => {
entries.forEach(entry => {
entry.goal.classList.toggle('seen', entry.isIntersecting);
});
});
// …and after appending every wrapper:
this.observer.observe(wrapper);
Creating an Intro Animation with GSAP
To complete the expertise with a robust visible entry, we’ll animate all at present seen tiles from the middle of the display screen into their pure grid positions. This creates a sophisticated, attention-grabbing introduction and provides a way of depth and intentionality to the format.
We’ll use GSAP for this animation, using gsap.set()
to place components immediately, and gsap.to()
with staggered timing to animate them into place.
Choosing Seen Tiles for Animation
First, we filter all tile components to incorporate solely these at present seen within the viewport. This avoids animating offscreen components and retains the intro light-weight and targeted:
import gsap from 'gsap';
initIntro() {
this.introItems = [...this.$container.querySelectorAll('.item-wrapper')].filter((merchandise) => {
const rect = merchandise.getBoundingClientRect();
return (
rect.x > -rect.width &&
rect.x < window.innerWidth + rect.width &&
rect.y > -rect.peak &&
rect.y < window.innerHeight + rect.peak
);
});
this.introItems.forEach((merchandise) => {
const rect = merchandise.getBoundingClientRect();
const x = -rect.x + window.innerWidth * 0.5 - rect.width * 0.5;
const y = -rect.y + window.innerHeight * 0.5 - rect.peak * 0.5;
gsap.set(merchandise, { x, y });
});
}
Animating to Remaining Positions
As soon as the tiles are centered, we animate them outward to their pure positions utilizing a {smooth} easing curve and staggered timing:
intro() {
gsap.to(this.introItems.reverse(), {
length: 2,
ease: 'expo.inOut',
x: 0,
y: 0,
stagger: 0.05,
});
}
x: 0, y: 0
restores the unique place set by way of CSS transforms.expo.inOut
gives a dramatic however {smooth} easing curve.stagger
creates a cascading impact, enhancing visible rhythm
Wrapping Up
What we’ve constructed is a scrollable, draggable picture grid with a parallax impact, visibility animations, and a {smooth} GSAP-powered intro. It’s a versatile base you may adapt for artistic galleries, interactive backgrounds, or experimental interfaces.