One of the best ways to learn is by recreating an interaction you’ve seen out in the wild and building it from scratch. It pushes you to notice the small details, understand the logic behind the animation, and strengthen your problem-solving skills along the way.
So today we’ll dive into rebuilding the smooth, draggable product grid from the Palmer website, originally crafted by Uncommon with Kevin Masselink, Alexis Sejourné, and Dylan Brouwer. The goal is to understand how this kind of interaction works under the hood and code the basics from scratch.
Along the way, you’ll learn how to structure a flexible grid, implement draggable navigation, and add smooth scroll-based movement. We’ll also explore how to animate products as they enter or leave the viewport, and finish with a polished product detail transition using Flip and SplitText for dynamic text reveals.
Let’s get started!
Grid Setup
The Markup
Let’s not try to be original and, as always, start with the basics. Before we get into the animations, we need a clear structure to work with — something simple, predictable, and easy to build upon.
<div class="container">
<div class="grid">
<div class="column">
<div class="product">
<div><img src="./public/img-3.png" /></div>
</div>
<div class="product">
<div><img src="./public/img-7.png" /></div>
</div>
<!-- repeat -->
</div>
<!-- repeat -->
</div>
</div>
What we have here is a .container
that fills the viewport, inside of which sits a .grid
divided into vertical columns. Each column stacks multiple .product
elements, and every product wraps around an image. It’s a minimal setup, but it lays the foundation for the draggable, animated experience we’re about to create.
The Style
Now that we’ve got the structure, let’s add some styling to make the grid usable. We’ll keep things straightforward and use Flexbox instead of CSS Grid, since Flexbox makes it easier to handle vertical offsets for alternating columns. This approach keeps the layout flexible and ready for animation.
.container {
position: fixed;
width: 100vw;
height: 100vh;
top: 0;
left: 0;
}
.grid {
position: absolute;
display: flex;
gap: 5vw;
cursor: grab;
}
.column {
display: flex;
flex-direction: column;
gap: 5vw;
}
.column:nth-child(even) {
margin-top: 10vw;
}
.product {
position: relative;
width: 18.5vw;
aspect-ratio: 1 / 1;
div {
width: 18.5vw;
aspect-ratio: 1 / 1;
}
img {
position: absolute;
width: 100%;
height: 100%;
object-fit: contain;
}
}

Animation
Okay, setup’s out of the way — now let’s jump into the fun part.
When developing interactive experiences, it helps to break things down into smaller parts. That way, each piece can be handled step by step without feeling overwhelming.
Here’s the structure I followed for this project:
1 – Introduction / Preloader
2 – Grid Navigation
3 – Product’s detail view transition
Introduction / Preloader
First, the grid isn’t centered by default, so we’ll fix that with a small utility function. This makes sure the grid always sits neatly in the middle of the screen, no matter the viewport size.
centerGrid() {
const gridWidth = this.grid.offsetWidth
const gridHeight = this.grid.offsetHeight
const windowWidth = window.innerWidth
const windowHeight = window.innerHeight
const centerX = (windowWidth - gridWidth) / 2
const centerY = (windowHeight - gridHeight) / 2
gsap.set(this.grid, {
x: centerX,
y: centerY
})
}
In the original Palmer reference, the experience starts with products appearing one by one in a slightly random order. After that reveal, the whole grid smoothly zooms into place.
To keep things simple, we’ll start with both the container and the products scaled down to 0.5
and the products fully transparent. Then we animate them back to full size and opacity, adding a random stagger so the images pop in at slightly different times.
The result is a dynamic but lightweight introduction that sets the tone for the rest of the interaction.
intro() {
this.centerGrid()
const timeline = gsap.timeline()
timeline.set(this.dom, { scale: .5 })
timeline.set(this.products, {
scale: 0.5,
opacity: 0,
})
timeline.to(this.products, {
scale: 1,
opacity: 1,
duration: 0.6,
ease: "power3.out",
stagger: { amount: 1.2, from: "random" }
})
timeline.to(this.dom, {
scale: 1,
duration: 1.2,
ease: "power3.inOut"
})
}
Grid Navigation
The grid looks good. Next, we need a way to navigate it: GSAP’s Draggable plugin is just what we need.
setupDraggable() {
this.draggable = Draggable.create(this.grid, {
type: "x,y",
bounds: {
minX: -(this.grid.offsetWidth - window.innerWidth) - 200,
maxX: 200,
minY: -(this.grid.offsetHeight - window.innerHeight) - 100,
maxY: 100
},
inertia: true,
allowEventDefault: true,
edgeResistance: 0.9,
})[0]
}
It would be great if we could add scrolling too.
window.addEventListener("wheel", (e) => {
e.preventDefault()
const deltaX = -e.deltaX * 7
const deltaY = -e.deltaY * 7
const currentX = gsap.getProperty(this.grid, "x")
const currentY = gsap.getProperty(this.grid, "y")
const newX = currentX + deltaX
const newY = currentY + deltaY
const bounds = this.draggable.vars.bounds
const clampedX = Math.max(bounds.minX, Math.min(bounds.maxX, newX))
const clampedY = Math.max(bounds.minY, Math.min(bounds.maxY, newY))
gsap.to(this.grid, {
x: clampedX,
y: clampedY,
duration: 0.3,
ease: "power3.out"
})
}, { passive: false })
We can also make the products appear as we move around the grid.
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.target === this.currentProduct) return
if (entry.isIntersecting) {
gsap.to(entry.target, {
scale: 1,
opacity: 1,
duration: 0.5,
ease: "power2.out"
})
} else {
gsap.to(entry.target, {
opacity: 0,
scale: 0.5,
duration: 0.5,
ease: "power2.in"
})
}
})
}, { root: null, threshold: 0.1 })
Product’s detail view transition
When you click on a product, an overlay opens and displays the product’s details.
During this transition, the product’s image animates smoothly from its position in the grid to its position inside the overlay.
We build a simple overlay with minimal structure and styling and add an empty <div>
that will contain the product image.
<div class="details">
<div class="details__title">
<p>The title</p>
</div>
<div class="details__body">
<div class="details__thumb"></div>
<div class="details__texts">
<p>Lorem ipsum dolor, sit amet consectetur adipisicing elit...</p>
</div>
</div>
</div>
.details {
position: absolute;
top: 0;
left: 0;
width: 50vw;
height: 100vh;
padding: 4vw 2vw;
background-color: #FFF;
transform: translate3d(50vw, 0, 0);
}
.details__thumb {
position: relative;
width: 25vw;
aspect-ratio: 1 / 1;
z-index: 3;
will-change: transform;
}
/* etc */
To achieve this effect, we use GSAP’s Flip plugin. This plugin makes it easy to animate elements between two states by calculating the differences in position, size, scale, and other properties, then animating them seamlessly.
We capture the state of the product image, move it into the details thumbnail container, and then animate the transition from the captured state to its new position and size.
showDetails(product) {
gsap.to(this.dom, {
x: "50vw",
duration: 1.2,
ease: "power3.inOut",
})
gsap.to(this.details, {
x: 0,
duration: 1.2,
ease: "power3.inOut",
})
this.flipProduct(product)
}
flipProduct(product) {
this.currentProduct = product
this.originalParent = product.parentNode
if (this.observer) {
this.observer.unobserve(product)
}
const state = Flip.getState(product)
this.detailsThumb.appendChild(product)
Flip.from(state, {
absolute: true,
duration: 1.2,
ease: "power3.inOut",
});
}
We can add different text-reveal animations when a product’s details are shown, using the SplitText plugin.
const splitTitles = new SplitText(this.titles, {
type: "lines, chars",
mask: "lines",
charsClass: "char"
})
const splitTexts = new SplitText(this.texts, {
type: "lines",
mask: "lines",
linesClass: "line"
})
gsap.to(splitTitles.chars, {
y: 0,
duration: 1.1,
delay: 0.4,
ease: "power3.inOut",
stagger: 0.025
});
gsap.to(splitTexts.lines, {
y: 0,
duration: 1.1,
delay: 0.4,
ease: "power3.inOut",
stagger: 0.05
});
Final thoughts
I hope you enjoyed following along and picked up some useful techniques. Of course, there’s always room for further refinement—like experimenting with different easing functions or timing—but the core ideas are all here.
With this approach, you now have a handy toolkit for building smooth, draggable product grids or even simple image galleries. It’s something you can adapt and reuse in your own projects, and a good reminder of how much can be achieved with GSAP and its plugins when used thoughtfully.
A huge thanks to Codrops and to Manoela for giving me the opportunity to share this first article here 🙏 I’m really looking forward to hearing your feedback and thoughts!
See you around 👋
- Kiambu Web Design Services
- Kiambu SEO Services
- Kiambu Digital Marketing Services
- Kiambu Social Media Marketing Services
- Kiambu Lipa Pole Pole Phones
- Nyali Web Design Services
- Nyali SEO Services
- Nyali Lipa Pole Pole Phones
- Mombasa Lipa Pole Pole Phones
- Meru Web Design Services
- Meru SEO Services
- Meru Digital Marketing Services
- Meru Social Media Marketing Services
- CBD Nairobi Web Design Services
- Westlands Web Design Services
- Outer Ring Road Web Design Services
- Outer Ring Road SEO Services
- Thika Road Web Design Services
- Thika Road SEO Services
- Thika Road Digital Marketing Services
- Thika Road Lipa Pole Pole Phones
- Langata Web Design Services
- Langata SEO Services
- Langata Lipa Pole Pole Phones
- Mombasa Road Web Design Services
- Mombasa Road SEO Services
- Mombasa Road Lipa Pole Pole Phones
- Mombasa Road Digital Marketing Services
- Mombasa Road Social Media Marketing Services
- Karen Web Design Services
- Karen SEO Services
- Karen Digital Marketing Services
- Garden City Web Design Services
- Thika Road Mall Web Design Services
- Thika Road Mall SEO Services
- Thika Road Mall Lipa Pole Pole Phones
- Eastlands Web Design Services
- Eastlands SEO Services
- Eastlands Lipa Pole Pole Phones
- Donholm Web Design Services
- Donholm SEO Services
- Donholm Lipa Pole Pole Phones
- Ruai Web Design Services
- Ruai SEO Services
- Ruai Lipa Pole Phones