My (design) partner, Gaetan Ferhah, likes to send me his design and motion experiments throughout the week. It’s always fun to see what he’s working on, and it often sparks ideas for my own projects. One day, he sent over a quick concept for making a product grid feel a bit more creative and interactive. 💬 The idea for this tutorial came from that message.
We’ll explore a “grid to preview” hover interaction that transforms product cards into a full preview. As with many animations and interactions, there are usually several ways to approach the implementation—ranging in complexity. It can feel intimidating (or almost impossible) to recreate a designer’s vision from scratch. But I’m a huge fan of simplifying wherever possible and leaning on optical illusions (✨ fake it ’til you make it ✨).
For this tutorial, I knew I wanted to keep things straightforward and recreate the effect of puzzle pieces shifting into place using a combination of clip-path
animation and an image overlay.
Let’s break it down in a few steps:
- Layout and Overlay (HTML, CSS)Set up the initial layout and carefully match the position of the preview overlay to the grid.
- Build JavaScript structure (JavaScript)Creating some classes to keep us organised, add some interactivity (event listeners).
- Clip-Path Creation and Animation (CSS, JS, GSAP)Adding and animating the
clip-path
, including some calculations on resize—this forms a key part of the puzzle effect. - Moving Product Cards (JS, GSAP)Set up animations to move the product cards towards each other on hover.
- Preview Image Scaling (JS, GSAP)Slightly scaling down the preview overlay in response to the inward movement of the other elements.
- Adding Images (HTML, JS, GSAP)Enough with the solid colours, let’s add some images and a gallery animation.
- Debouncing events (JS)Debouncing the mouse-enter event to prevent excessive triggering and reduce jitter.
- Final tweaks Crossed the t’s and dotted the i’s—small clean-ups and improvements.
Layout and Overlay
At the foundation of every good tutorial is a solid HTML structure. In this step, we’ll create two key elements: the product grid and the overlay for the preview cards. Since both need a similar layout, we’ll place them inside the same container (.products
).
Our grid will consist of 8 products (4 columns by 2 rows) with a gutter of 5vw
. To keep things simple, I’m only adding the corresponding li
elements for the products, but not yet adding any other elements. In the HTML, you’ll notice there are two preview containers: one for the left side and one for the right. If you want to see the preview overlays right away, head to the CodePen and set the opacity of .product-preview
to 1
.
Why I Opted for Two Containers
At first, I planned to use just one preview container and move it to the opposite side of the hovered card by updating the grid-column-start
. That approach worked fine—until I got to testing.
When I hovered over a product card on the left and quickly switched to one on the right, I realised the problem: with only one container, I also had just one timeline controlling everything inside it. That made it basically impossible to manage the “in/out” transition between sides smoothly.
So, I decided to go with two containers—one for the left side and one for the right. This way, I could animate both sides independently and avoid timeline conflicts when switching between them.
See the Pen
Untitled by Gwen Bogaert (@gwen-bo)
on CodePen.
JavaScript Set-up
In this step, we’ll add some classes to keep things structured before adding our event listeners and initiating our timelines. To keep things organised, let’s split it into two classes: ProductGrid
and ProductPreview
.
ProductGrid
will be fairly basic, responsible for handling the split between left and right, and managing top-level event listeners (such as mouseenter
and mouseleave
on the product cards, and a general resize).
ProductPreview
is where the magic happens. ✨ This is where we’ll control everything that happens once a mouse event is triggered (enter or leave). To pass the ‘active’ product, we’ll define a setProduct
method, which, in later steps, will act as the starting point for controlling our GSAP animation(s).
Splitting Products (Left – Right)
In the ProductGrid
class, we will split all the products into left and right groups. We have 8 products arranged in 4 columns, with each row containing 4 items. We are splitting the product cards into left and right groups based on their column position.
this.ui.products.filter((_, i) => i % 4 === 2 || i % 4 === 3)
The logic relies on the modulo or remainder operator. The line above groups the product cards on the right. We use the index (i
) to check if it’s in the 3rd (i % 4 === 2
) or 4th (i % 4 === 3
) position of the row (remember, indexing starts at 0). The remaining products (with i % 4 === 0
or i % 4 === 1
) will be grouped on the left.
Now that we know which products belong to the left and right sides, we will initiate a ProductPreview
for both sides and pass along the products array. This will allow us to define productPreviewRight
and productPreviewLeft
.
To finalize this step, we will define event listeners. For each product, we’ll listen for mouseenter
and mouseleave
events, and either set or unset the active product (both internally and in the corresponding ProductPreview
class). Additionally, we’ll add a resize
event listener, which is currently unused but will be set up for future use.
This is where we’re at so far (only changes in JavaScript):
See the Pen
Tutorial – step 2 (JavaScript structure) by Gwen Bogaert (@gwen-bo)
on CodePen.
Clip-path
At the base of our effect lies the clip-path
property and the ability to animate it with GSAP. If you’re not familiar with using clip-path
to clip content, I highly recommend this article by Sarah Soueidan.
Even though I’ve used clip-path
in many of my projects, I often struggle to remember exactly how to define the shape I’m looking for. As before, I’ve once again turned to the wonderful tool Clippy, to get a head start on defining (or exploring) clip-path
shapes. For me, it helps demystify which value influences which part of the shape.
Let’s start with the cross (from Clippy) and modify the points to create a more mathematical-looking cross (✚) instead of the religious version (✟).
clip-path: polygon(10% 25%, 35% 25%, 35% 0%, 65% 0%, 65% 25%, 90% 25%, 90% 50%, 65% 50%, 65% 100%, 35% 100%, 35% 50%, 10% 50%);
Feel free to experiment with some of the values, and soon you’ll notice that with small adjustments, we can get much closer to the desired shape! For example, by stretching the horizontal arms completely to the sides (set to 10%
and 90%
before) and shifting everything more equally towards the center (with a 10% difference from the center — so either 40%
or 60%
).
clip-path: polygon(0% 40%, 40% 40%, 40% 0%, 60% 0%, 60% 40%, 100% 40%, 100% 60%, 60% 60%, 60% 100%, 40% 100%, 40% 60%, 0% 60%);
And bada bing, bada boom! This clip-path
almost immediately creates the illusion that our single preview container is split into four parts — exactly the effect we want to achieve! Now, let’s move on to animating the clip-path
to get one step closer to our final result:
Animating Clip-paths
The concept of animating clip-paths
is relatively simple, but there are a few key things to keep in mind to ensure a smooth transition. One important consideration is that it’s best to define an equal number of points for both the start and end shapes.
The idea is fairly straightforward: we begin with the clipped parts hidden, and by the end of the animation, we want the clip-path
to disappear, revealing the entire preview container (by making the arms of the cross so thin that they’re barely visible or not visible at all). This can be achieved easily with a fromTo
animation in GSAP (though it’s also supported in CSS animations).

The Catch
You might think, “That’s it, we’re done!” — but alas, there’s a catch when it comes to using this as our puzzle effect. To make it look realistic, we need to ensure that the shape of the cross aligns with the underlying product grid. And that’s where a bit of JavaScript comes in!
We need to factor in the gutter of our grid (5vw
) to calculate the width of the arms of our cross shape. It could’ve been as simple as adding or subtracting (half!) of the gutter to/from the 50%, but… there’s a catch in the catch!

We’re not working with a square, but with a rectangle. Since our values are percentages, subtracting 2.5vw
(half of the gutter) from the center wouldn’t give us equal-sized arms. This is because there would still be a difference between the x and y dimensions, even when using the same percentage value. So, let’s take a look at how to fix that:
onResize()
const width, height = this.container.getBoundingClientRect()
const vw = window.innerWidth / 100
const armWidthVw = 5
const armWidthPx = armWidthVw * vw
this.armWidth =
x: (armWidthPx / width) * 100,
y: (armWidthPx / height) * 100
In the code above (triggered on each resize), we get the width and height of the preview container (which spans 4 product cards — 2 columns and 2 rows). We then calculate what percentage 5vw
would be, relative to both the width and height.
To conclude this step, we would have something like:
See the Pen
Tutorial – step 3 (clip path) by Gwen Bogaert (@gwen-bo)
on CodePen.
Moving Product Cards
Another step in the puzzle effect is moving the visible product cards together so they appear to form one piece. This step is fairly simple — we already know how much they need to move (again, gutter divided by 2 = 2.5vw
). The only thing we need to figure out is whether a card needs to move up, down, left, or right. And that’s where GSAP comes to the rescue!
We need to define both the vertical (y) and horizontal (x) movement for each element based on its index in the list. Since we only have 4 items, and they need to move inward, we can check whether the index is odd or even to determine the desired value for the horizontal movement. For vertical movement, we can decide whether it should move to the top or bottom depending on the position (top or bottom).
In GSAP, many properties (like x
, y
, scale
, etc.) can accept a function instead of a fixed value. When you pass a function, GSAP calls it for each target element individually.
Horizontal (x): cards with an even index (0, 2
) get shifted right by 2.5vw
, the other (two) move to the left. Vertical (y): cards with an index lower than 2 (0,1
) are located at the top, so need to move down, the other (two) move up.
x: (i) =>
return i % 2 === 0 ? '2.5vw' : '-2.5vw'
,
y: (i) =>
return i < 2 ? '2.5vw' : '-2.5vw'
See the Pen
Tutorial – step 3 (clip path) by Gwen Bogaert (@gwen-bo)
on CodePen.
Preview Image (Scaling)
Cool, we’re slowly getting there! We have our clip-path
animating in and out on hover, and the cards are moving inward as well. However, you might notice that the cards and the image no longer have an exact overlap once the cards have been moved. To fix that and make everything more seamless, we’ll apply a slight scale to the preview container.

This is where a bit of extra calculation comes in, because we want it to scale relative to the gutter. So we take into account the height and width of the container.
onResize()
const width, height = this.container.getBoundingClientRect()
const vw = window.innerWidth / 100
// ...armWidth calculation (see previous step)
const widthInVw = width / vw
const heightInVw = height / vw
const shrinkVw = 5
this.scaleFactor =
x: (widthInVw - shrinkVw) / widthInVw,
y: (heightInVw - shrinkVw) / heightInVw
This calculation determines a scale factor to shrink our preview container inward, matching the cards coming together. First, the rectangle’s width/height (in pixels) is converted into viewport width units (vw) by dividing it by the pixel value of 1vw
. Next, the shrink amount (5vw
) is subtracted from that width/height. Finally, the result is divided by the original width in vw to calculate the scale factor (which will be slightly below 1). Since we’re working with a rectangle, the scale factor for the x and y axes will be slightly different.
In the codepen below, you’ll see the puzzle effect coming along nicely on each container. Pink are the product cards (not moving), red and blue are the preview containers.
See the Pen
Tutorial – step 4 (moving cards) by Gwen Bogaert (@gwen-bo)
on CodePen.
Adding Pictures
Let’s make our grid a little more fun to look at!
In this step, we’re going to add the product images to our grid, and the product preview images inside the preview container. Once that’s done, we’ll start our image gallery on hover.
The HTML changes are relatively simple. We’ll add an image to each product li
element and… not do anything with it. We’ll just leave the image as is.
<li class="product" >
<img src="./assets/product-1.png" alt="alt" width="1024" height="1536" />
</li>
The rest of the magic will happen inside the preview container. Each container will hold the preview images of the products from the other side (those that will be visible). So, the left container will contain the images of the 4 products on the right, and the right container will contain the images of the 4 products on the left. Here’s an example of one of these:
<div class="product-preview --left">
<div class="product-preview__images">
<!-- all detail images -->
<img data-id="2" src="./assets/product-2.png" alt="product-image" width="1024" height="1536" />
<img data-id="2" src="./assets/product-2-detail-1.png" alt="product-image" width="1024" height="1536" />
<img data-id="3" src="./assets/product-3.png" alt="product-image" width="1024" height="1536" />
<img data-id="3" src="./assets/product-3-detail-1.png" alt="product-image" width="1024" height="1536" />
<img data-id="6" src="./assets/product-6.png" alt="product-image" width="1024" height="1024" />
<img data-id="6" src="./assets/product-6-detail-1.png" alt="product-image" width="1024" height="1024" />
<img data-id="7" src="./assets/product-7.png" alt="product-image" width="1024" height="1536" />
<img data-id="7" src="./assets/product-7-detail-1.png" alt="product-image" width="1024" height="1536" />
<!-- end of all detail images -->
</div>
<div class="product-preview__inside masked-preview">
</div>
</div>
Once that’s done, we can initialise by querying those images in the constructor of the ProductPreview
, sorting them by their dataset.id
. This will allow us to easily access the images later via the data-index
attribute that each product has. To sum up, at the end of our animate-in timeline, we can call startPreviewGallery
, which will handle our gallery effect.
startPreviewGallery(id)
const images = this.ui.previewImagesPerID[id]
const timeline = gsap.timeline( repeat: -1 )
// first image is already visible (do not hide)
gsap.set([...images].slice(1), opacity: 0 )
images.forEach((image) =>
timeline
.set(images, opacity: 0 ) // Hide all images
.set(image, opacity: 1 ) // Show only this one
.to(image, duration: 0, opacity: 1 , '+=0.5')
)
this.galleryTimeline = timeline
Debouncing
One thing I’d like to do is debounce hover effects, especially if they are more complex or take longer to complete. To achieve this, we’ll use a simple (and vanilla) JavaScript approach with setTimeout
. Each time a hover event is triggered, we’ll set a very short timer that acts as a debouncer, preventing the effect from firing if someone is just “passing by” on their way to the product card on the other side of the grid.
I ended up using a 100ms “cooldown” before triggering the animation, which helped reduce unnecessary animation starts and minimise jitter when interacting with the cards.
productMouseEnter(product, preview)
// If another timer (aka hover) was running, cancel it
if (this.hoverDelay)
clearTimeout(this.hoverDelay)
this.hoverDelay = null
// Start a new timer
this.hoverDelay = setTimeout(() =>
this.activeProduct = product
preview.setProduct(product)
this.hoverDelay = null // clear reference
, 100)
productMouseLeave()
// If user leaves before debounce completes
if (this.hoverDelay)
clearTimeout(this.hoverDelay)
this.hoverDelay = null
if (this.activeProduct)
const preview = this.getProductSide(this.activeProduct)
preview.setProduct(null)
this.activeProduct = null
Final Tweaks
I can’t believe we’re almost there! Next up, it’s time to piece everything together and add some small tweaks, like experimenting with easings, etc. The final timeline I ended up with (which plays or reverses depending on mouseenter
or mouseleave
) is:
buildTimeline()
const x, y = this.armWidth
this.timeline = gsap
.timeline(
paused: true,
defaults:
ease: 'power2.inOut'
)
.addLabel('preview', 0)
.addLabel('products', 0)
.fromTo(this.container, opacity: 0 , opacity: 1 , 'preview')
.fromTo(this.container, scale: 1 , scaleX: this.scaleFactor.x, scaleY: this.scaleFactor.y, transformOrigin: 'center center' , 'preview')
.to(
this.products,
opacity: 0,
x: (i) =>
return i % 2 === 0 ? '2.5vw' : '-2.5vw'
,
y: (i) =>
return i < 2 ? '2.5vw' : '-2.5vw'
,
'products'
)
.fromTo(
this.masked,
clipPath: `polygon(
$50 - x / 2% 0%,
$50 + x / 2% 0%,
$50 + x / 2% $50 - y / 2%,
100% $50 - y / 2%,
100% $50 + y / 2%,
$50 + x / 2% $50 + y / 2%,
$50 + x / 2% 100%,
$50 - x / 2% 100%,
$50 - x / 2% $50 + y / 2%,
0% $50 + y / 2%,
0% $50 - y / 2%,
$50 - x / 2% $50 - y / 2%
)`
,
clipPath: `polygon(
50% 0%,
50% 0%,
50% 50%,
100% 50%,
100% 50%,
50% 50%,
50% 100%,
50% 100%,
50% 50%,
0% 50%,
0% 50%,
50% 50%
)`
,
'preview'
)
Final Result
📝 A quick note on usability & accessibility
While this interaction may look cool and visually engaging, it’s important to be mindful of usability and accessibility. In its current form, this effect relies quite heavily on motion and hover interactions, which may not be ideal for all users. Here are a few things that should be considered if you’d be planning on implementing a similar effect:
- Motion sensitivity: Be sure to respect the user’s
prefers-reduced-motion
setting. You can easily check this with a media query and provide a simplified or static alternative for users who prefer minimal motion. - Keyboard navigation: Since this interaction is hover-based, it’s not currently accessible via keyboard. If you’d like to make it more inclusive, consider adding support for focus events and ensuring that all interactive elements can be reached and triggered using a keyboard.
Think of this as a playful, exploratory layer — not a foundation. Use it thoughtfully, and prioritise accessibility where it counts. 💛
Acknowledgements
I am aware that this tutorial assumes an ideal scenario of only 8 products, because what happens if you have more? I didn’t test it out myself, but the important part is that the preview containers feel like an exact overlay of the product grid. If more cards are present, you could try ‘mapping’ the coordinates of the preview container to the 8 products that are completely in view. Or.. go crazy with your own approach if you had another idea. That’s the beauty of it, there’s always many approaches that would lead to the same (visual) outcome. 🪄
Thank you so much for following along! A big thanks to Codrops for giving me the opportunity to contribute. I’m excited to see what you’ll create when inspired by this tutorial! If you have any questions, feel free to drop me a line!