Coding the Sliced Image Hover Effect from Quai Network

Learn how to code a similar hover animation to the one seen on the website of Quai Network.

I have a serious weakness for futuristic interfaces. And hover effects. Naturally, when I stumbled upon the website of Quai Network made by Unseen Studio, I totally fell in love with the whole design and interactive effects.

Besides the amazing graphics and interactivity, I found their hover animation to be a really nice one to rebuild using a dynamic clip path effect:

Please note that the effect we’ll be constructing in this tutorial won’t be an exact replica, but rather a similar animation that emulates the motion found on their website.

For the animations, we’ll use GSAP by GreenSock.

Let’s get started!

The Markup

When we look at the effect on Quai Network, we can see that several slices of the same image move into view once we hover a grid item. The slices are actually super tall and the image inside is repeated. There’s also a cool letter shuffling effect. We will make a simpler version, by using slices that are as high as the grid item.

What we need is some sort of wrapper where we insert all the images that will have a clip-path applied to them, creating those slices. Let’s set up a basic structure that we’ll then augment dynamically in our JavaScript. This structure will also serve as a fallback:

<article class="card">
    <div class="card__img" style="background-image:url(img/img1.jpg)"></div>
    <span class="card__date" data-splitting>02/18/2074</span>
    <h2 class="card__title" data-splitting>Code CR-4519: Anomaly Detection in Array</h2>
    <a href="#" class="card__link" data-splitting>Read the article</a>
</article>

The texts we’ll want to shuffle, will have the data-splitting attribute as we’ll use Splitting.js to help us with that by separating all words into letter spans. The card__img element is our container for all the slices we’ll generate.

Additionally, to achieve some sort of “reveal” effect, we will need another wrapper element for all those slices. Something like this:

<div class="card__img" >
	<div class="card__img-wrap">
		<div class="card__img-inner"></div>
		<div class="card__img-inner"></div>
		<div class="card__img-inner"></div>
		<div class="card__img-inner"></div>
		<div class="card__img-inner"></div>
	</div>
</div>

Where each card__img-inner will have a clip-path applied to and also the same background image set.

The card__img-wrap element will be translated initially and then moved into view. By translating the inner slices in the opposite direction, we can create a sort of “unreveal” animation.

Let’s move on to the styles.

The CSS

The first thing that’s important for our effect is the style of the card:

.card {
	display: grid;
	grid-template-rows: auto 1fr auto;
	cursor: pointer;
	position: relative;
	min-height: 60vh;
	padding: 4vw;
	border-bottom: 1px solid var(--color-border);
    overflow: hidden;
}

Using a grid layout, we can position the elements easily. You could also use flexbox here.

Now, let’s define the styles for the crucial part, the slices and all involved elements. We need to make sure that everything is nicely stackable, so we use absolute positioning for all of them. We don’t want the pieces to fly out of the card, so we make sure that overflows are hidden on the right elements.

To make sure we don’t see any nasty gaps caused by anti-aliasing when using clip-path, we apply a little trick. For each slice bigger than one, we add a pixel to the width and pull the element back by setting the left accordingly. This we will do dynamically. So, for example, the second slice will have a width of 100% + 1px and a left of -1px. This will make it move on top of the first slice, hiding any potential gaps! While we will set the lefts in our JavaScript, we can take care of the width in the CSS by passing over two variables that will be defined in our script, --columns and --rows.

If we stack the slices horizontally, we’ll set the –rows variable, which will cause the height to adjust. If we stack the slices vertically, we’ll set the –columns variable, which in turn will cause the width to adjust.

.card__img,
.card__img-wrap,
.card__img-inner {
	position: absolute;
	top: 0;
	left: 0;
	width: 100%;
	height: 100%;
}

.card__img,
.card__img-inner {
	background-size: cover;
	background-position: 50% 50%;
}

.card__img,
.card__img-wrap {
	overflow: hidden;
}

.card__img {
	z-index: -1;
	pointer-events: none;
	--columns: 0;
	--rows: 0;
}

.js .card__img {
	opacity: 0;
	background-image: none !important;
}

.card__img-inner {
	filter: brightness(0.6);
	width: calc(100% + (var(--columns) - 1) * 1px);
	height: calc(100% + (var(--rows) - 1) * 1px);
}

Last, but not least, let’s style the remaining elements:

.card__date {
	display: flex;
	align-content: center;
	align-items: center;
	line-height: 1;
	position: relative;
}

.card__date::before {
	content: '';
	width: 15px;
	height: 15px;
	border: 1px solid var(--color-link);
	background: var(--color-bg-date);
	margin: 0 10px 4px 0;
}

.card__title {
	font-weight: 400;
	font-size: clamp(1.5rem,5vw,2.5rem);
}

.card__link {
	position: relative;
}

.card__link::before {
	content: '+';
	margin-right: 10px;
}

The JavaScript

We will need two JavaScript files: card.js, which will contain our Card class, and index.js, which will initialize our cards.

Creating the Card class

Our Card class will handle the functionality of the card component. We’ll start by defining some properties and a constructor in our card.js file.

// Class representing a Card
export class Card {
    // Initialize DOM and style related properties
    DOM = {
        // main DOM element
        el: null,
        // .card__img element
        img: null,
        // .card__img-wrap element (dynamically created in the layout function)
        imgWrap: null,
        // .card__img-inner "slice" elements (dynamically created in the layout function)
        slices: null,
        // .card__date element
        date: null,
        // .card__title element
        title: null,
        // .card__link element
        link: null,
    };
    // Card image url
    imageURL;
    // Settings
    settings = {
        // vertical || horizontal alignment
        orientation: 'vertical', 
        // Total number of slices for the inner images (clip paths)
        slicesTotal: 5,
        // Animation values
        animation: {
            duration: 0.5,
            ease: 'power3.inOut'
        }
    };
    
    /**
     * Sets up the necessary elements, data, and event listeners for a Card instance.
     * @param {HTMLElement} DOM_el - The DOM element that represents the card.
     * @param {Object} options - The options for customizing the card. These options will override the default settings.
     */
    constructor(DOM_el, options) {
        // Merge settings and options.
        this.settings = Object.assign({}, this.settings, options);

        this.DOM.el = DOM_el;
        this.DOM.img = this.DOM.el.querySelector('.card__img');
        this.DOM.date = this.DOM.el.querySelector('.card__date');
        this.DOM.title = this.DOM.el.querySelector('.card__title');
        this.DOM.link = this.DOM.el.querySelector('.card__link');

        // Splitting chars for date, title and link
        this.chars = {
            date: [...this.DOM.date.querySelectorAll('.char')],
            title: [...this.DOM.title.querySelectorAll('.char')],
            link: [...this.DOM.link.querySelectorAll('.char')]
        };
        
        // Save those initial char values
        [...this.chars.date, ...this.chars.title, ...this.chars.link].forEach(char => {
            char.dataset.initial = char.innerHTML;
        });

        // Extracts the image URL from the style of the DOM image element
        this.imageURL = this.DOM.img.getAttribute('style').match(/url\((['"])?(.*?)\1\)/)[2];

        // Calls the `layout` function to create the necessary structure for the card
        this.layout();

        // Initialize the events
        this.initEvents();
    }
    ...
}

The constructor takes two arguments: a DOM element, and an options object. These properties allow us to customize each card instance. We store various elements inside the card element, including the image, date, title, and link. We also split the characters in these elements and save their initial values, which will be used for the animation later.

Layout and Animation Functions

Now, let’s define some functions that will handle the layout and animations of our card.

The layout() function focuses on modifying the layout of the card image by slicing it into multiple sections. With the help of this function, we can create a structured arrangement of image slices within the .card__img element.

By adjusting the slicesTotal setting, we can control the number of slices that will be generated. Each slice will be represented by an element with the .card__img-inner class, containing the same background image as the original .card__img element.

To group these slices together, we’ll utilize a wrapper element called .card__img-wrap, which will be appended to the .card__img element.

Additionally, the function will dynamically set the appropriate CSS variable (--columns or --rows) to match the slicesTotal value. Finally, we’ll apply a clip-path to each slice using the setClipPath function:

layout() {
    this.DOM.imgWrap = document.createElement('div');

    this.DOM.imgWrap.classList = 'card__img-wrap';

    let slicesStr = '';

    for (let i = 0; i < this.settings.slicesTotal; ++i) {
        slicesStr += `<div class="card__img-inner" style="background-image:url(${this.imageURL})"></div>`;
    }

    this.DOM.imgWrap.innerHTML = slicesStr;

    this.DOM.slices = this.DOM.imgWrap.querySelectorAll('.card__img-inner');

    // append the new wrap element to the card img element
    this.DOM.img.appendChild(this.DOM.imgWrap);

    // Set the --columns or --rows CSS variable value to be the same as the settings.slicesTotal
    this.DOM.img.style.setProperty(this.settings.orientation === 'vertical' ? '--columns' : '--rows', this.settings.slicesTotal);

    // Set the clip paths of each slice
    this.setClipPath();
}

The setClipPath() function is responsible for applying a clip-path CSS property to each slice.

To achieve this effect, the function divides each slice along either the vertical or horizontal axis, depending on the orientation setting. The slicing is based on the value specified in slicesTotal, which determines the number of slices to be created.

Furthermore, the setClipPath() function incorporates an offset mechanism to address any potential gap issues that may arise between the slices. By offsetting each slice slightly, we can ensure a seamless look.

setClipPath() {
    this.DOM.slices.forEach((slice, position) => {
        let a1 = position*100/this.settings.slicesTotal;
        let b1 = position*100/this.settings.slicesTotal + 100/this.settings.slicesTotal;

        gsap.set(slice, {
            clipPath: this.settings.orientation === 'vertical' ? 
                `polygon(${a1}% 0%, ${b1}% 0%, ${b1}% 100%, ${a1}% 100%)` :
                `polygon(0% ${a1}%, 100% ${a1}%, 100% ${b1}%, 0% ${b1}%)`
        });
        const isVertical = this.settings.orientation === 'vertical';
        gsap.set(slice, { [isVertical ? 'left' : 'top']: position*-1 });
    });
}

So, the layout function creates the necessary structure for our card’s image and slices it into multiple sections, while the setClipPath function sets a clip-path CSS property for each slice, creating a slicing effect.

Next, we’ll also need the initEvents function to initialize event listeners for mouseenter and mouseleave events.

Mouse Events and Animation

The Card class also needs two event functions, mouseEnter and mouseLeave, that animate the card when the mouse enters or leaves the card.

The mouseEnter() function is triggered when the mouse enters the targeted element.

Within the mouseEnter() function, several important actions take place. First, the characters of the date, title, and link properties of the object are shuffled. Then the image and image wrapper elements are animated from an offset position, smoothly transitioning them into view. The direction of this animation, whether vertical or horizontal, is determined by the orientation setting specified.

Additionally, the individual slices of the image undergo an animation, coming into view from random offset positions.

mouseEnter() {
    const isVertical = this.settings.orientation === 'vertical';

    this.shuffleChars(this.chars.date);
    this.shuffleChars(this.chars.title);
    this.shuffleChars(this.chars.link);

    gsap
    .timeline({
        defaults: {
            duration: this.settings.animation.duration,
            ease: this.settings.animation.ease
        }
    })
    .addLabel('start', 0)
    .fromTo(this.DOM.img, {
        [isVertical ? 'yPercent' : 'xPercent']: 100,
        opacity: 0
    }, {
        [isVertical ? 'yPercent' : 'xPercent']: 0,
        opacity: 1
    }, 'start')
    .fromTo(this.DOM.imgWrap, {
        [isVertical ? 'yPercent' : 'xPercent']: -100
    }, {
        [isVertical ? 'yPercent' : 'xPercent']: 0
    }, 'start')
    .fromTo(this.DOM.slices, {
        [isVertical ? 'yPercent' : 'xPercent']: pos => pos % 2 ? gsap.utils.random(-75, -25) : gsap.utils.random(25, 75)
    }, {
        [isVertical ? 'yPercent' : 'xPercent']: 0
    }, 'start');
}

The mouseLeave() function is triggered when the mouse leaves the targeted element.

When the mouseLeave() function is invoked, it initiates an animation that transitions the image, the image wrapper, and the slices out of view. Again, the specific direction of this animation, either vertical or horizontal, is determined by the orientation setting specified.

By animating these elements out of view, we create a our exit animation when the user moves the mouse away from the element.

mouseLeave() {
    const isVertical = this.settings.orientation === 'vertical';

    gsap
    .timeline({
        defaults: {
            duration: this.settings.animation.duration,
            ease: this.settings.animation.ease
        }
    })
    .addLabel('start', 0)
    .to(this.DOM.img, {
        [isVertical ? 'yPercent' : 'xPercent']: 100,
        opacity: 0
    }, 'start')
    .to(this.DOM.imgWrap, {
        [isVertical ? 'yPercent' : 'xPercent']: -100
    }, 'start')
    .to(this.DOM.slices, {
        [isVertical ? 'yPercent' : 'xPercent']: pos => pos % 2 ? gsap.utils.random(-75, 25) : gsap.utils.random(25, 75)
    }, 'start')
}

Initializing the Cards

Now that our Card class is set up, we just need to initialize it for each card element on our page. This is done in our index.js file.

// Importing the necessary helper function to preload images from 'utils.js' file
import { preloadImages } from './utils.js';

// Importing the Card class from 'card.js' file
import { Card } from './card.js';

// Calling the Splitting function to split the text into individual characters, 
// it's a text animation library which splits the text for complex animations.
Splitting();

// An array to hold different configurations/settings for different sets of cards. 
// Each setting object includes the orientation, number of slices, and animation details.
const settingsArray = [
    // 1st set of settings: Vertical orientation with 5 slices.
    { 
        orientation: 'vertical', 
        slicesTotal: 5 
    },
    // 2nd set of settings: Vertical orientation with 15 slices.
    { 
        orientation: 'vertical', 
        slicesTotal: 15 
    },
    // 3rd set of settings: Horizontal orientation with 5 slices and specific animation duration and easing.
    { 
        orientation: 'horizontal', 
        slicesTotal: 5,
        animation: {
            duration: 0.6,
            ease: 'expo.inOut'
        }
    },
    // 4th set of settings: Horizontal orientation with 15 slices and specific animation duration and easing.
    { 
        orientation: 'horizontal', 
        slicesTotal: 15,
        animation: {
            duration: 0.6,
            ease: 'expo.inOut'
        }
    },
];

// Initialize the Cards
[...document.querySelectorAll('.card')].forEach((cardEl, index) => {
    new Card(cardEl, settingsArray[Math.floor(index / 3) % settingsArray.length]);
});

// Preload images, then remove loader (loading class) from body
preloadImages('.canvas-wrap').then(() => document.body.classList.remove('loading'));

Here, we select all elements with the classname “card” and create a new instance of our Card class for each one. We also provide some settings for each card, which are used in our Card class to customize each card’s appearance and animation.

If you didn’t get confused with all the cards and classes by now, congratulations, you’ve made it until the end 😉

Thanks for reading the tutorial and I hope it gave you some insight into coding effects like these.

Make sure to download the code or play with the demo using different configurations for the slices!

A little easter egg: try using 50 slices or more, it makes a really cool variation of the effect 🙂 But remember, each slice is a div, so don’t abuse 😉

If you enjoyed this tutorial, please help spreading the word:

Manoela Ilic

Manoela is the main tinkerer at Codrops. With a background in coding and passion for all things design, she creates web experiments and keeps frontend professionals informed about the latest trends.

Stay in the loop: Get your dose of frontend twice a week

Fresh news, inspo, code demos, and UI animations—zero fluff, all quality. Make your Mondays and Thursdays creative!