My (design) accomplice, Gaetan Ferhah, likes to ship me his design and movement experiments all through the week. It’s all the time enjoyable to see what he’s engaged on, and it usually sparks concepts for my very own initiatives. In the future, he despatched over a fast idea for making a product grid really feel a bit extra inventive and interactive. 💬 The concept for this tutorial got here from that message.
We’ll discover a “grid to preview” hover interplay that transforms product playing cards right into a full preview. As with many animations and interactions, there are often a number of methods to strategy the implementation—ranging in complexity. It could really feel intimidating (or virtually unimaginable) to recreate a designer’s imaginative and prescient from scratch. However I’m an enormous fan of simplifying wherever potential and leaning on optical illusions (✨ faux it ’til you make it ✨).
For this tutorial, I knew I needed to maintain issues simple and recreate the impact of puzzle items shifting into place utilizing a mix of clip-path
animation and a picture overlay.
Let’s break it down in a couple of steps:
- Structure and Overlay (HTML, CSS)Arrange the preliminary structure and punctiliously match the place of the preview overlay to the grid.
- Construct JavaScript construction (JavaScript)Creating some courses to maintain us organised, add some interactivity (occasion listeners).
- Clip-Path Creation and Animation (CSS, JS, GSAP)Including and animating the
clip-path
, together with some calculations on resize—this types a key a part of the puzzle impact. - Shifting Product Playing cards (JS, GSAP)Arrange animations to maneuver the product playing cards in direction of one another on hover.
- Preview Picture Scaling (JS, GSAP)Barely cutting down the preview overlay in response to the inward motion of the opposite parts.
- Including Photos (HTML, JS, GSAP)Sufficient with the stable colors, let’s add some pictures and a gallery animation.
- Debouncing occasions (JS)Debouncing the mouse-enter occasion to stop extreme triggering and scale back jitter.
- Ultimate tweaks Crossed the t’s and dotted the i’s—small clean-ups and enhancements.
Structure and Overlay
On the basis of each good tutorial is a stable HTML construction. On this step, we’ll create two key parts: the product grid and the overlay for the preview playing cards. Since each want an analogous structure, we’ll place them inside the identical container (.merchandise
).
Our grid will encompass 8 merchandise (4 columns by 2 rows) with a gutter of 5vw
. To maintain issues easy, I’m solely including the corresponding li
parts for the merchandise, however not but including another parts. Within the HTML, you’ll discover there are two preview containers: one for the left facet and one for the precise. If you wish to see the preview overlays instantly, head to the CodePen and set the opacity of .product-preview
to 1
.
Why I Opted for Two Containers
At first, I deliberate to make use of only one preview container and transfer it to the other facet of the hovered card by updating the grid-column-start
. That strategy labored wonderful—till I acquired to testing.
Once I hovered over a product card on the left and rapidly switched to 1 on the precise, I realised the issue: with just one container, I additionally had only one timeline controlling every little thing inside it. That made it mainly unimaginable to handle the “in/out” transition between sides easily.
So, I made a decision to go together with two containers—one for the left facet and one for the precise. This fashion, I may animate either side independently and keep away from timeline conflicts when switching between them.
See the Pen
Untitled by Gwen Bogaert (@gwen-bo)
on CodePen.
JavaScript Set-up
On this step, we’ll add some courses to maintain issues structured earlier than including our occasion listeners and initiating our timelines. To maintain issues organised, let’s cut up it into two courses: ProductGrid
and ProductPreview
.
ProductGrid
will likely be pretty primary, liable for dealing with the cut up between left and proper, and managing top-level occasion listeners (reminiscent of mouseenter
and mouseleave
on the product playing cards, and a normal resize).
ProductPreview
is the place the magic occurs. ✨ That is the place we’ll management every little thing that occurs as soon as a mouse occasion is triggered (enter or go away). To go the ‘lively’ product, we’ll outline a setProduct
technique, which, in later steps, will act as the start line for controlling our GSAP animation(s).
Splitting Merchandise (Left – Proper)
Within the ProductGrid
class, we’ll cut up all of the merchandise into left and proper teams. We now have 8 merchandise organized in 4 columns, with every row containing 4 gadgets. We’re splitting the product playing cards into left and proper teams primarily based on their column place.
this.ui.merchandise.filter((_, i) => i % 4 === 2 || i % 4 === 3)
The logic depends on the modulo or the rest operator. The road above teams the product playing cards on the precise. We use the index (i
) to verify if it’s within the third (i % 4 === 2
) or 4th (i % 4 === 3
) place of the row (bear in mind, indexing begins at 0). The remaining merchandise (with i % 4 === 0
or i % 4 === 1
) will likely be grouped on the left.
Now that we all know which merchandise belong to the left and proper sides, we’ll provoke a ProductPreview
for either side and go alongside the merchandise array. This can permit us to outline productPreviewRight
and productPreviewLeft
.
To finalize this step, we’ll outline occasion listeners. For every product, we’ll hear for mouseenter
and mouseleave
occasions, and both set or unset the lively product (each internally and within the corresponding ProductPreview
class). Moreover, we’ll add a resize
occasion listener, which is presently unused however will likely be arrange for future use.
That is the place we’re at to date (solely adjustments in JavaScript):
See the Pen
Tutorial – step 2 (JavaScript construction) by Gwen Bogaert (@gwen-bo)
on CodePen.
Clip-path
On the base of our impact lies the clip-path
property and the flexibility to animate it with GSAP. In case you’re not accustomed to utilizing clip-path
to clip content material, I extremely advocate this text by Sarah Soueidan.
Regardless that I’ve used clip-path
in lots of my initiatives, I usually wrestle to recollect precisely the right way to outline the form I’m on the lookout for. As earlier than, I’ve as soon as once more turned to the fantastic software Clippy, to get a head begin on defining (or exploring) clip-path
shapes. For me, it helps demystify which worth influences which a part of the form.
Let’s begin with the cross (from Clippy) and modify the factors to create a extra mathematical-looking cross (✚) as an alternative of the spiritual model (✟).
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%);
Be at liberty to experiment with a few of the values, and shortly you’ll discover that with small changes, we will get a lot nearer to the specified form! For instance, by stretching the horizontal arms utterly to the perimeters (set to 10%
and 90%
earlier than) and shifting every little thing extra equally in direction of the middle (with a ten% distinction from the middle — so both 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 increase! This clip-path
virtually instantly creates the phantasm that our single preview container is cut up into 4 components — precisely the impact we wish to obtain! Now, let’s transfer on to animating the clip-path
to get one step nearer to our closing end result:
Animating Clip-paths
The idea of animating clip-paths
is comparatively easy, however there are a couple of key issues to bear in mind to make sure a clean transition. One vital consideration is that it’s greatest to outline an equal variety of factors for each the beginning and finish shapes.
The concept is pretty simple: we start with the clipped components hidden, and by the tip of the animation, we wish the clip-path
to vanish, revealing the complete preview container (by making the arms of the cross so skinny that they’re barely seen or not seen in any respect). This may be achieved simply with a fromTo
animation in GSAP (although it’s additionally supported in CSS animations).

The Catch
You would possibly assume, “That’s it, we’re achieved!” — however alas, there’s a catch in relation to utilizing this as our puzzle impact. To make it look sensible, we have to be certain that the form of the cross aligns with the underlying product grid. And that’s the place a little bit of JavaScript is available in!
We have to issue within the gutter of our grid (5vw
) to calculate the width of the arms of our cross form. It may’ve been so simple as including or subtracting (half!) of the gutter to/from the 50%, however… there’s a catch within the catch!

We’re not working with a sq., however with a rectangle. Since our values are percentages, subtracting 2.5vw
(half of the gutter) from the middle wouldn’t give us equal-sized arms. It is because there would nonetheless be a distinction between the x and y dimensions, even when utilizing the identical share worth. So, let’s check out the right way to repair that:
onResize() {
const { width, peak } = this.container.getBoundingClientRect()
const vw = window.innerWidth / 100
const armWidthVw = 5
const armWidthPx = armWidthVw * vw
this.armWidth = {
x: (armWidthPx / width) * 100,
y: (armWidthPx / peak) * 100
}
}
Within the code above (triggered on every resize), we get the width and peak of the preview container (which spans 4 product playing cards — 2 columns and a couple of rows). We then calculate what share 5vw
can be, relative to each the width and peak.
To conclude this step, we might have one thing like:
See the Pen
Tutorial – step 3 (clip path) by Gwen Bogaert (@gwen-bo)
on CodePen.
Shifting Product Playing cards
One other step within the puzzle impact is shifting the seen product playing cards collectively so they seem to kind one piece. This step is pretty easy — we already understand how a lot they should transfer (once more, gutter divided by 2 = 2.5vw
). The one factor we have to work out is whether or not a card wants to maneuver up, down, left, or proper. And that’s the place GSAP involves the rescue!
We have to outline each the vertical (y) and horizontal (x) motion for every component primarily based on its index within the checklist. Since we solely have 4 gadgets, and they should transfer inward, we will verify whether or not the index is odd and even to find out the specified worth for the horizontal motion. For vertical motion, we will resolve whether or not it ought to transfer to the highest or backside relying on the place (high or backside).
In GSAP, many properties (like x
, y
, scale
, and so on.) can settle for a operate as an alternative of a hard and fast worth. Once you go a operate, GSAP calls it for every goal component individually.
Horizontal (x): playing cards with a fair index (0, 2
) get shifted proper by 2.5vw
, the opposite (two) transfer to the left. Vertical (y): playing cards with an index decrease than 2 (0,1
) are positioned on the high, so want to maneuver down, the opposite (two) transfer 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 Picture (Scaling)
Cool, we’re slowly getting there! We now have our clip-path
animating out and in on hover, and the playing cards are shifting inward as nicely. Nonetheless, you would possibly discover that the playing cards and the picture now not have an actual overlap as soon as the playing cards have been moved. To repair that and make every little thing extra seamless, we’ll apply a slight scale to the preview container.

That is the place a bit of additional calculation is available in, as a result of we wish it to scale relative to the gutter. So we have in mind the peak and width of the container.
onResize() {
const { width, peak } = this.container.getBoundingClientRect()
const vw = window.innerWidth / 100
// ...armWidth calculation (see earlier step)
const widthInVw = width / vw
const heightInVw = peak / vw
const shrinkVw = 5
this.scaleFactor = {
x: (widthInVw - shrinkVw) / widthInVw,
y: (heightInVw - shrinkVw) / heightInVw
}
}
This calculation determines a scale issue to shrink our preview container inward, matching the playing cards coming collectively. First, the rectangle’s width/peak (in pixels) is transformed into viewport width items (vw) by dividing it by the pixel worth of 1vw
. Subsequent, the shrink quantity (5vw
) is subtracted from that width/peak. Lastly, the result’s divided by the unique width in vw to calculate the size issue (which will likely be barely under 1). Since we’re working with a rectangle, the size issue for the x and y axes will likely be barely completely different.
Within the codepen under, you’ll see the puzzle impact coming alongside properly on every container. Pink are the product playing cards (not shifting), purple and blue are the preview containers.
See the Pen
Tutorial – step 4 (shifting playing cards) by Gwen Bogaert (@gwen-bo)
on CodePen.
Including Footage
Let’s make our grid a bit extra enjoyable to take a look at!
On this step, we’re going so as to add the product pictures to our grid, and the product preview pictures contained in the preview container. As soon as that’s achieved, we’ll begin our picture gallery on hover.
The HTML adjustments are comparatively easy. We’ll add a picture to every product li
component and… not do something with it. We’ll simply go away the picture as is.
<li class="product" >
<img src="./belongings/product-1.png" alt="alt" width="1024" peak="1536" />
</li>
The remainder of the magic will occur contained in the preview container. Every container will maintain the preview pictures of the merchandise from the opposite facet (those who will likely be seen). So, the left container will comprise the pictures of the 4 merchandise on the precise, and the precise container will comprise the pictures of the 4 merchandise on the left. Right here’s an instance of certainly one of these:
<div class="product-preview --left">
<div class="product-preview__images">
<!-- all element pictures -->
<img data-id="2" src="./belongings/product-2.png" alt="product-image" width="1024" peak="1536" />
<img data-id="2" src="./belongings/product-2-detail-1.png" alt="product-image" width="1024" peak="1536" />
<img data-id="3" src="./belongings/product-3.png" alt="product-image" width="1024" peak="1536" />
<img data-id="3" src="./belongings/product-3-detail-1.png" alt="product-image" width="1024" peak="1536" />
<img data-id="6" src="./belongings/product-6.png" alt="product-image" width="1024" peak="1024" />
<img data-id="6" src="./belongings/product-6-detail-1.png" alt="product-image" width="1024" peak="1024" />
<img data-id="7" src="./belongings/product-7.png" alt="product-image" width="1024" peak="1536" />
<img data-id="7" src="./belongings/product-7-detail-1.png" alt="product-image" width="1024" peak="1536" />
<!-- finish of all element pictures -->
</div>
<div class="product-preview__inside masked-preview">
</div>
</div>
As soon as that’s achieved, we will initialise by querying these pictures within the constructor of the ProductPreview
, sorting them by their dataset.id
. This can permit us to simply entry the pictures later by way of the data-index
attribute that every product has. To sum up, on the finish of our animate-in timeline, we will name startPreviewGallery
, which is able to deal with our gallery impact.
startPreviewGallery(id) {
const pictures = this.ui.previewImagesPerID[id]
const timeline = gsap.timeline({ repeat: -1 })
// first picture is already seen (don't disguise)
gsap.set([...images].slice(1), { opacity: 0 })
pictures.forEach((picture) => {
timeline
.set(pictures, { opacity: 0 }) // Disguise all pictures
.set(picture, { opacity: 1 }) // Present solely this one
.to(picture, { period: 0, opacity: 1 }, '+=0.5')
})
this.galleryTimeline = timeline
}
Debouncing
One factor I’d love to do is debounce hover results, particularly if they’re extra advanced or take longer to finish. To realize this, we’ll use a easy (and vanilla) JavaScript strategy with setTimeout
. Every time a hover occasion is triggered, we’ll set a really quick timer that acts as a debouncer, stopping the impact from firing if somebody is simply “passing by” on their technique to the product card on the opposite facet of the grid.
I ended up utilizing a 100ms “cooldown” earlier than triggering the animation, which helped scale back pointless animation begins and minimise jitter when interacting with the playing cards.
productMouseEnter(product, preview) {
// If one other timer (aka hover) was working, cancel it
if (this.hoverDelay) {
clearTimeout(this.hoverDelay)
this.hoverDelay = null
}
// Begin a brand new timer
this.hoverDelay = setTimeout(() => {
this.activeProduct = product
preview.setProduct(product)
this.hoverDelay = null // clear reference
}, 100)
}
productMouseLeave() {
// If person leaves earlier than 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
}
}
Ultimate Tweaks
I can’t consider we’re virtually there! Subsequent up, it’s time to piece every little thing collectively and add some small tweaks, like experimenting with easings, and so on. The ultimate timeline I ended up with (which performs or reverses relying 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('merchandise', 0)
.fromTo(this.container, { opacity: 0 }, { opacity: 1 }, 'preview')
.fromTo(this.container, { scale: 1 }, { scaleX: this.scaleFactor.x, scaleY: this.scaleFactor.y, transformOrigin: 'middle middle' }, 'preview')
.to(
this.merchandise,
{
opacity: 0,
x: (i) => {
return i % 2 === 0 ? '2.5vw' : '-2.5vw'
},
y: (i) => {
return i < 2 ? '2.5vw' : '-2.5vw'
}
},
'merchandise'
)
.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'
)
}
Ultimate End result
📝 A fast notice on usability & accessibility
Whereas this interplay could look cool and visually partaking, it’s vital to be aware of usability and accessibility. In its present kind, this impact depends fairly closely on movement and hover interactions, which will not be perfect for all customers. Right here are some things that must be thought of for those who’d be planning on implementing an analogous impact:
- Movement sensitivity: Make sure to respect the person’s
prefers-reduced-motion
setting. You possibly can simply verify this with a media question and supply a simplified or static various for customers preferring minimal movement. - Keyboard navigation: Since this interplay is hover-based, it’s not presently accessible by way of keyboard. In case you’d wish to make it extra inclusive, take into account including assist for focus occasions and guaranteeing that each one interactive parts will be reached and triggered utilizing a keyboard.
Consider this as a playful, exploratory layer — not a basis. Use it thoughtfully, and prioritise accessibility the place it counts. 💛
Acknowledgements
I’m conscious that this tutorial assumes a perfect situation of solely 8 merchandise, as a result of what occurs when you’ve got extra? I didn’t try it out myself, however the vital half is that the preview containers really feel like an actual overlay of the product grid. If extra playing cards are current, you may attempt ‘mapping’ the coordinates of the preview container to the 8 merchandise which can be utterly in view. Or.. go loopy with your personal strategy for those who had one other thought. That’s the great thing about it, there’s all the time many approaches that may result in the identical (visible) end result. 🪄
Thanks a lot for following alongside! A giant because of Codrops for giving me the chance to contribute. I’m excited to see what you’ll create when impressed by this tutorial! In case you have any questions, be happy to drop me a line!