Hello! I’m Andrea Biason, a Creative Frontend Developer at Adoratorio Studio passionate about volleyball, code, and things in motion (including GIFs, by the way!).
In this article, we’ll discover how to approach a simple e-commerce landing page and transform it into a more interactive and engaging experience for the user with the final goal of increasing conversions while also making the user journey more engaging at such a crucial, yet often disregarded moment.
“I have a friend who needs a landing page for his products. Are you in?”
When I got called for this project, my first thought was that I didn’t want it to be the usual e-commerce site.
So, I asked the designer, “How much creative freedom do I have?”.
Fortunately, the answer was “Do whatever you want” so I started thinking about what I could do to make the result engaging.
“What if we add an animation to the CTA button when you click it? The cart icon could appear…”
Uhm, actually…no! An interaction on the ‘Add to cart’ button was the right solution, but I didn’t want to go with something already seen a million times — I wanted to try creating something distinctive. The idea came from thinking about two completely separate and unrelated components: a gallery and a mouse trail on the cursor. I thought it might be interesting to try merging them, using the many images we had available to create a sort of path from the product to the cart.
This type of interaction would not only engage the user visually but also guide their gaze towards the checkout process and the checkout process.
Let’s take a closer look at the code.
The Markup
<section class="content"> <div class="products"> <ul class="products__list"> <li class="products__item" data-id="product-01" data-price="15" data-name="Product 01" data-cover="/images/product-01-cover.jpg"> <div class="products__images"> <img class="products__main-image" src="/images/product-01-cover.jpg" alt="Product 01"> <div class="products__gallery"> <img class="products__gallery-item" src="/images/galleries/product-01/01.jpg" alt="Product 01 gallery"> <img class="products__gallery-item" src="/images/galleries/product-01/02.jpg" alt="Product 01 gallery"> <img class="products__gallery-item" src="/images/galleries/product-01/03.jpg" alt="Product 01 gallery"> <img class="products__gallery-item" src="/images/galleries/product-01/04.jpg" alt="Product 01 gallery"> <img class="products__gallery-item" src="/images/galleries/product-01/05.jpg" alt="Product 01 gallery"> <img class="products__gallery-item" src="/images/product-01-cover.jpg" alt="Product 01 gallery"> </div> </div> <button type="button" class="products__cta button">Add to cart</button> </li> <li>... </li> <li>... </li> <li>... </li> <li>... </li> <li>... </li> </ul> </div>
</section> <aside class="cart"> <div class="cart__bg"></div> <div class="cart__inner"> <div class="cart__inner-close">Close</div> <div class="cart__inner-bg"></div> <div class="cart-items"></div> <div class="cart-total cart-grid"> <div class="cart-total__inner"> <div class="cart-total__label">Total:</div> <div class="cart-total__amount">€ 0</div> <div class="cart-total__taxes"> Delivery fee and tax <br> calculated at checkout </div> <a class="cart-total__checkout-btn button" href="#">Checkout</a> </div> </div> </div>
</aside>
The HTML structure is very simple. A CSS grid was created to quickly set up the product display, and within each item, a wrapper was created for the main image and gallery. The reason for creating a wrapper is to have a single element with a fixed height, allowing all images inside to scale to 100% of the parent size, making responsive management easier as well.
At this point, the first decision came up: should I create all image nodes directly within the markup or append the gallery images only when the button is clicked? The first approach makes the HTML more verbose and increases the number of nodes on the page, while the second would require creating all images at runtime and adding them to the node, delaying the animation’s start and potentially causing issues with managing the queue for both processes.
I chose, therefore, to include all images directly in the HTML. This choice also helped bypass a possible additional issue: by retrieving all the img nodes, I was able to preload all images during the initial loading phase while the preloader was still visible.
Alright, the HTML is ready; it’s time to move on to creating a class to manage the products.
The “Products” class
The Products class has a very simple structure and will mainly handle:
- Identifying the x and y coordinates of the cart in the header, which is the point towards which the animation will be directed;
- Adding a click listener on the CTAs to set up the elements and start the animation;
- Creating the animation timeline;
- Resetting the elements once the animation is complete.
export default class Products { constructor() { this.products = [...document.querySelectorAll('.products__item')]; this.ctas = [...document.querySelectorAll('.products__cta')]; this.cartButton = document.querySelector('.cart-button'); this.cartButtonCoords = { x: 0, y: 0 }; this.currentProduct = null; this.currentGallery = []; this.otherProducts = []; this.isTopRow = false; this.init(); } init() { this.setCartButtonCoords(); this.ctas.forEach((cta, i) => { cta.addEventListener('click', () => { this.currentProduct = this.products[i]; this.otherProducts = this.products.filter((prod, index) => index !== i); this.currentGallery = [...this.currentProduct.querySelectorAll('.products__gallery-item')]; this.isTopRow = window.innerWidth > 768 && i < 3; this.addToCart(); }) }) window.addEventListener('resize', debounce(() => { this.setCartButtonCoords(); })) } setCartButtonCoords() { const { x, y } = this.cartButton.getBoundingClientRect(); this.cartButtonCoords = { x, y }; } ...
Let’s quickly break down the init method:
- The
this.setCartButtonCoords
method is called, which simply retrieves the x and y coordinates of the button in the header usinggetBoundingClientRect()
; - A click listener is created for the CTAs, where the animation will be executed. This method is straightforward: it simply defines the constructor values with the current item to be animated, the other items that need to disappear, the active gallery to be animated, and the
this.isTopRow
field, which will be used to define the animation direction; - A listener is created to monitor resize events, resetting the cart coordinates every time the screen size changes. The debounce function optimizes this by preventing the method from running on every pixel resize, instead triggering it after a timeout at the end of the browser’s resize operation.
Now, let’s take a look at the juicy part: the this.addToCart
method, where the GSAP timeline is created.
The “Add to cart” animation
Let’s go through the evolution of the timeline step by step, starting from the basics.
The very first step is to highlight the selected product and make the other items disappear, then return everything to the original state once the animation is complete.
tl.to(this.otherProducts, { scale: 0.8, autoAlpha: 0.05, duration: 0.6, stagger: 0.04, ease: 'power2.out',
}, 'start'); tl.to(this.currentProduct, { scale: 1.05, duration: 1, ease: 'power2.out',
}, 'start+=0.7'); tl.to([this.currentProduct, this.otherProducts], { scale: 1, autoAlpha: 1, duration: 0.8, stagger: 0.03, ease: 'power2.out',
}, 'start+=1.6');
The idea behind the animation is to move the elements toward the cart coordinates, so the first step in the timeline will be to tween the x and y coordinates of the gallery images.
tl.to(this.currentGallery, { x: this.cartButtonCoords.x, y: this.cartButtonCoords.y, stagger: { from: 'end', each: 0.04, }, duration: 1.8, ease: 'power2.inOut'
});
We immediately face the first problem: the images are moving downward instead of upward, as we might expect. The reason is simple: we are adding the cart’s coordinates to the current coordinates of the image.
The goal, therefore, will be to calculate the distance between the image and the cart’s position, and subtract that distance during the tween. To do this, before initializing the timeline, we retrieve the right and y coordinates of the current image and subtract them from the cart’s coordinates.
const { y, right} = this.currentGallery[0].getBoundingClientRect(); tl.to(this.currentGallery, { x: this.cartButtonCoords.x - right, y: this.cartButtonCoords.y - y, ...
Now, as we can see, the images are moving in the correct direction towards the button.Let’s refine this first step by adding a fade-out effect to the images as they approach their final position, adjusting the scale and autoAlpha
properties.
tl.to(this.currentGallery, { x: this.cartButtonCoords.x - right, y: this.cartButtonCoords.y - y, scale: 0, autoAlpha: 0, stagger: { from: 'end', each: 0.04, }, duration: 1.8, ease: 'power2.inOut'
}, 'start');
Alright, this could already be a good result by adjusting the timeline duration and easing, but the idea I had in mind was to create a more elaborate trail.
So, I thought of splitting the timeline into two steps: a first step where the images would exit the frame, and a second step where they would head towards the cart.
And this is where GSAP keyframes came to my rescue!
The first step is to go back to the beginning of the animation and also retrieve the height using the getBoundingClientRect()
method. This value is then used to move the images by 150% at 40% of the animation, before directing them towards the cart in the next 60% of the animation.
tl.to(this.currentGallery, { keyframes: { '40%': { y: height * 1.5, scale: 0.8, autoAlpha: 1, }, '100%': { x: this.cartButtonCoords.x - right, y: this.cartButtonCoords.y - y, scale: 0, autoAlpha: 0, }, }, stagger: { from: 'end', each: 0.04, }, duration: 1.8, ease: 'power2.inOut',
}, 'start');
Here’s the final result, but at this point, another issue arises: the animation works well for the top row, but the effect is lost in the bottom row.
So close, yet so far.
Ok, how do we handle the animation for the bottom rows? By reversing the direction: instead of moving downward, they will take the opposite path, detaching upward first, and then moving towards the cart.
So, let’s start using this.isTopRow
, which we created in the constructor, to define whether the animation involves an item from the top row or the bottom row.
The first step involves the transformOrigin
of the images.
gsap.set(this.currentGallery, { transformOrigin: this.isTopRow ? 'top right' : 'bottom left' });
Then, we proceed by modifying the direction within the keyframes, also retrieving the left position using the initial getBoundingClientRect()
const { y, left, right, height } = this.currentGallery[0].getBoundingClientRect(); ... keyframes: { '40%': { y: this.isTopRow ? height * 1.5 : -height * 1.5, scale: this.isTopRow ? 0.8 : 0.5, autoAlpha: 1, }, '100%': { x: this.isTopRow ? this.cartButtonCoords.x - right : this.cartButtonCoords.x - left, y: this.isTopRow ? this.cartButtonCoords.y - y : this.cartButtonCoords.y - y - height, scale: 0, autoAlpha: 0, },
},
Ok, we’re almost there! There’s still a small imperfection in the animation of the bottom row, caused by the transformOrigin
we just set at the beginning of the timeline.
To visually correct the final point, we’ll subtract an arbitrary value from the destination of the animation, corresponding to the size of the cart item count badge.
'100%': { x: this.isTopRow ? this.cartButtonCoords.x - right : this.cartButtonCoords.x - left - 12, // removing half button width y: this.isTopRow ? this.cartButtonCoords.y - y : this.cartButtonCoords.y - y - height + 25, // adding full button height scale: 0, autoAlpha: 0,
},
Here’s the final result:
Now, let’s take care of resetting the animation at the end of the timeline:
onComplete: () => { gsap.set(this.currentGallery, { scale: 1, autoAlpha: 1, y: 0, x: 0 }); gsap.set(gallery, { autoAlpha: 0 }); this.resetAnimation() },
We simply return the elements of the gallery, which were just animated, to their original position (which is exactly overlapping with the main image that remains visible, so no differences are noticeable), set the opacity to 0, and execute the method that clears the objects in the constructor.
resetAnimation() { this.currentProduct = null; this.currentGallery = []; this.otherProducts = []; }
The reset function might not even need to be executed, since every time the click event is triggered on the CTA, the array is rewritten. However, it’s still preferable to keep the arrays empty once we no longer need to work with the elements contained in them.
Ok, are we done? I’d say not yet, we still need to take care of the cart!
Let’s not leave things unfinished.
The Cart class was divided into two logical blocks during development: the first one only involves the purchase scenario, and the second one focuses solely on the logic for the entrance and exit animations of the sidebar.
Let’s start with the product management scenario:
addItemToCart(el) { const { id, price, name, cover } = el.dataset; const index = this.cartItems.findIndex((el) => el.id === id); if (index < 0) { const newItem = { id, price, name, cover, quantity: 1 }; this.cartItems.push(newItem); const newCartItem = this.appendItem(newItem); this.cartItemsList.append(newCartItem); } else this.cartItems[index].quantity += 1; this.updateCart();
}
The method for adding a product to the cart is very simple, and here too it divides the logic into two scenarios:
- The clicked CTA is for a new product;
- The clicked CTA is for a product already in the cart.
The this.cartItems array in the constructor represents the list of all items added to the cart, and is therefore used within the method to switch between the possible scenarios. If the product is not already in the cart, it is pushed into the this.cartItems array, and the HTML node is created through the this.appendItem method. If the product is already in the cart, it is simply retrieved by its index, and the quantity is updated.
Let’s quickly go through the this.appendItem method:
appendItem(item) { const cartItem = document.createElement('div'); cartItem.classList.add('cart-item', 'cart-grid'); cartItem.innerHTML = ` <img class="cart-item__img" src="${item.cover}" alt="${item.name}"> <div class="cart-item__details"> <span class="cart-item__details-title">${item.name}</span> <button class="cart-item__remove-btn">Remove</button> <div class="cart-item__details-wrap"> <span class="cart-item__details-label">Quantity:</span> <div class="cart-item__details-actions"> <button class="cart-item__minus-button">-</button> <span class="cart-item__quantity">${item.quantity}</span> <button class="cart-item__plus-button">+</button> </div> <span class="cart-item__details-price">€ ${item.price}</span> </div> </div> `; const removeButton = cartItem.querySelector('.cart-item__remove-btn'); const plusButton = cartItem.querySelector('.cart-item__plus-button'); const minusButton = cartItem.querySelector('.cart-item__minus-button'); removeButton.addEventListener('click', () => this.removeItemFromCart(item.id)); plusButton.addEventListener('click', () => this.updateQuantity(item.id, 1)); minusButton.addEventListener('click', () => this.updateQuantity(item.id, -1)); return cartItem;
)
In addition to adding the HTML node, I also set up all the listeners for the various buttons that make up the UI, linking them to their respective methods:
- The “Remove” button will execute the
this.removeItemFromCart(item.id)
method to remove the object from the array of active products and the HTML node. - The “+” and “-” buttons modify the quantity of products in the cart and execute the this.updateQuantity(item.id, 1 / -1) method, passing as a parameter the quantity to add or remove.
At the end of each cart modification (addition/removal/quantity change), I’ve set up an update method to modify the checkout total.
updateCart() { const cartElementsQuantities = [...document.querySelectorAll('.cart-item__quantity')]; this.cartButtonNumber.innerHTML = Object.values(this.cartItems).length; let cartAmount = 0; Object.values(this.cartItems).forEach((item, i) => { cartElementsQuantities[i].innerHTML = item.quantity; cartAmount+= item.price * item.quantity }) this.cartTotal.innerHTML = `€ ${cartAmount}`;
}
This code was created for basic functionality and would need to be expanded to work properly with an e-commerce site. In my case, having chosen the Shopify platform, I used the shopify-buy library to manage the APIs and sync the cart checkout with the final checkout on the platform, but each platform has its own APIs to handle this.
Another possible implementation, slightly more complex but definitely more user-friendly, would be to manage the products added to the cart by saving them in LocalStorage, ensuring they remain in memory even if the user reloads the page.
The final step to complete the product addition to the cart will be, therefore, to execute the addItemToCart
method within the timeline created earlier.
tl.add(() => { Cart.addItemToCart(this.currentProduct);
}, 'start+=0.6');
In this way, during the animation of the images, the current product will also be pushed into the cart.
And why not animate the button with the number of items at this point?
Let’s bring it home.
Within the init method of the Cart
class, we initialize the button that will be animated setting elements to 0 scale.
Then we simply add, still within the main cart addition timeline, the this.cartButtonAnimationEnter
method, but only if the current number of products in the cart is 0.
tl.add(() => { if (Cart.cartItems.length === 0) Cart.cartButtonAnimationEnter(); Cart.addItemToCart(this.currentProduct);
}, 'start+=0.6'); cartButtonAnimationEnter() { const tl = gsap.timeline(); tl.addLabel('start'); tl.to(this.cartButtonLabel, { x: -35, duration: 0.4, ease: 'power2.out' }, 'start'); tl.to([this.cartButtonNumber, this.cartButtonBg], { scale: 1, stagger: 0.1, duration: 0.8, ease: 'elastic.out(1.3, 0.9)', }, 'start'); return tl;
}
And now, the final part, the most juicy one, which involves the entrance and exit animation of the cart.
So let it out and let it in.
Still within the init method of the Cart
class, we’ll manage two fundamental steps for the entire flow.
The first step is to execute the setup functions for the elements to animate, both cart button and cart opening animation.
The second step is to manage event listeners for enter and leave animations, based on cart and close buttons interactions:
init() { this.cartButtonAnimationSetup(); this.cartAnimationSetup(); this.cartButton.addEventListener('click', () => { if (this.isAnimating) return; document.body.classList.add('locked'); this.isAnimating = true; this.cartAnimationEnter().then(() => { this.cartOpened = true; this.isAnimating = false; }) }) this.cartClose.addEventListener('click', () => { if (this.isAnimating) return; document.body.classList.remove('locked'); this.isAnimating = true; this.cartAnimationLeave().then(() => { this.cartOpened = false; this.isAnimating = false; }) }) }
Let’s quickly analyze:
this.isAnimating
is used to prevent the overlap of the two timelines (this is a stylistic choice, not a mandatory one; the alternative is to manage the element queues with thekillTweensOf
method from GSAP). If an animation is in progress, its reverse cannot be triggered until it’s completed;- The locked class is added to the body to block scrolling;
- The entrance/exit animation is triggered, after which the values
this.isAnimating
andthis.cartOpened
are set.
One last small note on the entrance animation:
cartAnimationEnter() { this.animatingElements.items = [...this.cart.querySelectorAll('.cart-item')]; if (this.animatingElements.items.length > 0) gsap.set(this.animatingElements.items, { x: 30, autoAlpha: 0 }); const tl = gsap.timeline({ onStart: () => gsap.set(this.cart, { xPercent: 0 }) }); tl.addLabel('start'); tl.to([this.animatingElements.bg, this.animatingElements.innerBg], { xPercent: 0, stagger: 0.1, duration: 2.2, ease: 'expo.inOut', }, 'start'); tl.to(this.animatingElements.close, { x: 0, autoAlpha: 1, stagger: 0.1, duration: 1, ease: 'power2.out', }, 'start+=1.3'); if (this.animatingElements.items.length > 0) { tl.to(this.animatingElements.items, { x: 0, autoAlpha: 1, stagger: 0.1, duration: 1, ease: 'power2.out', }, 'start+=1.4'); } if (this.animatingElements.noProds) { tl.to(this.animatingElements.noProds, { x: 0, autoAlpha: 1, stagger: 0.1, duration: 1, ease: 'power2.out', }, 'start+=1.4'); } tl.to(this.animatingElements.total, { scale: 1, autoAlpha: 1, stagger: 0.1, duration: 1, ease: 'power2.out', }, 'start+=1.6'); return tl; };
this.animatingElements.items
is not defined within the this.cartAnimationSetup
function because the number of elements changes each time they are added by the animation, while this is only called during the initialization of the Cart class.
If we didn’t set the elements every time we run the entrance animation, this.animatingElements.items would always be an empty array, and therefore, we would never see the items added to the cart.
The leave animation simply repositions the elements outside of the layout:
cartAnimationLeave() { const tl = gsap.timeline({ onComplete: () => gsap.set(this.cart, { xPercent: 100 }) }); tl.addLabel('start'); tl.to([this.animatingElements.bg, this.animatingElements.innerBg], { xPercent: 110, stagger: 0.1, duration: 1.5, ease: 'expo.inOut', }, 'start'); if (this.animatingElements.items.length > 0) { tl.to(this.animatingElements.items, { x: 30, autoAlpha: 0, stagger: 0.1, duration: 0.8, ease: 'power2.out', }, 'start'); } if (this.animatingElements.noProds) { tl.to(this.animatingElements.noProds, { x: 30, autoAlpha: 0, stagger: 0.1, duration: 0.8, ease: 'power2.out', }, 'start'); } tl.to(this.animatingElements.close, { x: 30, autoAlpha: 0, stagger: 0.1, duration: 0.8, ease: 'power2.out', }, 'start'); tl.to(this.animatingElements.total, { scale: 0.9, autoAlpha: 0, stagger: 0.1, duration: 0.8, ease: 'power2.out', }, 'start'); return tl;
}
And here is the final result with the cart animation!
Ah, maybe you might’ve caught on but I forgot to mention that I’m a big fan of The Office and that…