How to Create a Cover Page Transition

The other day I saw this nice Dribbble shot by Vitalii Burhonskyi that shows several lovely transitions. One of them is what I would call a “cover transition” where a black cover animates to hide some content and then some new content reveals on top of the cover (which is of a different color than the previous view).

I love the fact that we can have a plethora of different animations here to “unreveal” or show the new content. So I’ve created a sequence that we’ll explore today in a short tutorial where we’ll have a look at some highlights of the structure and animations.

We’ll be using GSAP from GreenSock as animation library.

Markup and Styles

Our initial view will show three items in a grid structure:

Let’s have a look at the markup for this. We’ll have a division for all items:

<div class="content">
	<div class="item">
		<span class="item__meta">2020</span>
		<h2 class="item__title">Alex Moulder</h2>
		<div class="item__img"><div class="item__img-inner" style="background-image:url(img/1.jpg)"></div></div>
		<p class="item__desc">I am only waiting for love to give myself up at last into his hands. That is why it is so late and why I have been guilty of such omissions.</p>
		<a class="item__link">view</a>
	</div>
	<div class="item">
		<span class="item__meta">2021</span>
		<h2 class="item__title">Aria Bennett</h2>
		<div class="item__img"><div class="item__img-inner" style="background-image:url(img/2.jpg)"></div></div>
		<p class="item__desc">They come with their laws and their codes to bind me fast; but I evade them ever, for I am only waiting for love to give myself up at last into his hands.</p>
		<a class="item__link">view</a>
	</div>
	<div class="item">
		<span class="item__meta">2022</span>
		<h2 class="item__title">Jimmy Hughes</h2>
		<div class="item__img"><div class="item__img-inner" style="background-image:url(img/3.jpg)"></div></div>
		<p class="item__desc">Clouds heap upon clouds and it darkens. Ah, love, why dost thou let me wait outside at the door all alone?</p>
		<a class="item__link">view</a>
	</div>
</div>

First, our whole page will be of a grid layout:

main {
	padding: 1.5rem 2.5rem 3rem;
	height: 100vh;
	display: grid;
	grid-template-columns: 100%;
	grid-template-areas: 'frame' 'content';
	grid-template-rows: min-content 1fr;
	grid-row-gap: 8vh;
}

The content division will have the following style to show the items in a grid:

.content {
	grid-area: content;
	max-width: 400px;
}

@media screen and (min-width: 53em) {
	.content {
		max-width: none;
		display: grid;
		grid-template-columns: repeat(3,1fr);
		grid-template-rows: 100%;
		grid-column-gap: 5vw;
	}
}

We only want to show the items side-by-side when we are on a large screen. So we add a media query.

For the item’s inner elements we’ll have the following style:

.item {
	margin-bottom: 5rem;
	display: grid;
	grid-template-columns: 100%;
	grid-template-rows: 1rem auto auto 1fr auto;
}

.item__title {
	font-family: kudryashev-d-excontrast-sans, sans-serif;
	font-weight: 300;
	font-size: 2rem;
	margin-bottom: 0.5rem;
}

.item__img {
	position: relative;
	overflow: hidden;
	width: 100%;
	aspect-ratio: 500/333;
}

.item__img-inner {
	background-position: 50% 45%;
	background-size: cover;
	width: 100%;
	height: 100%;
}

.item__desc {
	margin-top: 2.5rem;
	line-height: 1.1;
}

.item__link {
	cursor: pointer;
	text-transform: lowercase;
	width: 100%;
	padding: 1rem;
	color: var(--color-text);
	border: 1px solid var(--color-border);
	border-radius: 2rem;
	text-align: center;
}

.item__link:hover {
	background: var(--color-text);
	border-color: var(--color-text);
	color: var(--color-text-alt);
}

@media screen and (min-width: 53em) {
	.item {
		margin-bottom: 0;
	}
	.item__title {
		font-size: clamp(1.25rem,3vw,2rem);
	}
}

The image element has a nested structure that will allow us to make a little zoom effect. An interesting thing here is the aspect-ratio property that allows us to set responsive image dimensions according to its real size while using the background-image property.

When we click on the item button, we’ll show a cover animation. This will be the two elements animating their scale transform to cover the entire page:

<div class="overlay">
	<div class="overlay__row"></div>
	<div class="overlay__row"></div>
</div>

Let’s add the following style:

.overlay {
	position: fixed;
	top: 0;
	left: 0;
	width: 100%;
	height: 100%;
	display: grid;
	grid-template-columns: 100%;
	pointer-events: none;
	grid-template-rows: repeat(2,1fr);
}

.overlay__row {
	background: var(--color-overlay);
	transform: scaleY(0);
	will-change: transform;
}

.overlay__row:first-child {
	transform-origin: 50% 0%;
}

.overlay__row:last-child {
	transform-origin: 50% 100%;
}

Setting the right transform origins for each one of the “rows” will make sure that they animate from up and from down, “closing” the current view and hiding it.

Next, let’s have a look at the view we will see after. This section will be called “previews” and we’ll add the following content:

<section class="previews">
	<div class="preview">
		<div class="preview__img"><div class="preview__img-inner" style="background-image:url(img/1_big.jpg)"></div></div>
		<h2 class="preview__title oh"><span class="oh__inner">Moulder</span></h2>
		<div class="preview__column preview__column--start">
			<span class="preview__column-title preview__column-title--main oh"><span class="oh__inner">Alex Moulder</span></span>
			<span class="oh"><span class="oh__inner">2020</span></span>
		</div>
		<div class="preview__column">
			<h3 class="preview__column-title oh"><span class="oh__inner">Location</span></h3>
			<p>And if it rains, a closed car at four. And we shall play a game of chess, pressing lidless eyes and waiting for a knock upon the door.</p>
		</div>
		<div class="preview__column">
			<h3 class="preview__column-title oh"><span class="oh__inner">Material</span></h3>
			<p>At the violet hour, when the eyes and back, turn upward from the desk, when the human engine waits.</p>
		</div>
		<button class="unbutton preview__back"><svg width="100px" height="18px" viewBox="0 0 50 9"><path vector-effect="non-scaling-stroke" d="m0 4.5 5-3m-5 3 5 3m45-3h-77"></path></svg></button>
	</div>

	<!-- preview -->

	<!-- preview -->
	
</section>

The large image will animate with a reveal/unreveal animation that is explained in detail in this tutorial. That’s why we use a nested structure just like on the item image. For the texts that we want to show by translating (and being cut off by the parent), we’ll use the .oh > .oh__inner structure. The idea behind this is to translate the oh__inner element to hide it. For multi-line text we will add this structure dynamically with JavaScript. The paragraphs in our preview__column divisions will be broken into lines using SplitType.

Let’s add the following styles for the line magic to work:

.oh {
	position: relative;
    overflow: hidden;
}

.oh__inner {
	will-change: transform;
    display: inline-block;
}

.line {
	transform-origin: 0 50%;
	white-space: nowrap;
	will-change: transform;
}

Now, let’s get this baby animated.

The JavaScript

Let’s define and instantiate some things first:

import { gsap } from 'gsap';
import { Item } from './item';
import { Preview } from './preview';

// body element
const body = document.body;

// .content element
const contentEl = document.querySelector('.content');

// frame element
const frameEl = document.querySelector('.frame');

// top and bottom overlay overlay elements
const overlayRows = [...document.querySelectorAll('.overlay__row')];

// Preview instances array
const previews = [];
[...document.querySelectorAll('.preview')].forEach(preview => previews.push(new Preview(preview)));

// Item instances array
const items = [];
[...document.querySelectorAll('.item')].forEach((item, pos) => items.push(new Item(item, previews[pos])));

Now, when we open an item, we’ll first set our content to not be clickable anymore. That’s done with a class.

Then we hide all those lines and elements that we want to animate in once we show the preview content. The preview-visible class helps us set some colors and pointer events. We also use it to hide our little frame at the top of the page, so that we can then show it again with an animation once the cover hides the initial view.

The image gets unrevealed by translating the image element in one direction and the inner element (which actually contains the background-image) in the opposite direction.

We also show all the lines and oh__inner elements finally.

const openItem = item => {
    
    gsap.timeline({
        defaults: {
            duration: 1, 
            ease: 'power3.inOut'
        }
    })
    .add(() => {
        // pointer events none to the content
        contentEl.classList.add('content--hidden');
    }, 'start')

    .addLabel('start', 0)
    .set([item.preview.DOM.innerElements, item.preview.DOM.backCtrl], {
        opacity: 0
    }, 'start')
    .to(overlayRows, {
        scaleY: 1
    }, 'start')

    .addLabel('content', 'start+=0.6')

    .add(() => {
        body.classList.add('preview-visible');

        gsap.set(frameEl, {
            opacity: 0
        }, 'start')
        item.preview.DOM.el.classList.add('preview--current');
    }, 'content')
    // Image animation (reveal animation)
    .to([item.preview.DOM.image, item.preview.DOM.imageInner], {
        startAt: {y: pos => pos ? '101%' : '-101%'},
        y: '0%'
    }, 'content')
    
    .add(() => {
        for (const line of item.preview.multiLines) {
            line.in();
        }
        gsap.set(item.preview.DOM.multiLineWrap, {
            opacity: 1,
            delay:0.1
        })
    }, 'content')
    // animate frame element
    .to(frameEl, {
        ease: 'expo',
        startAt: {y: '-100%', opacity: 0},
        opacity: 1,
        y: '0%'
    }, 'content+=0.3')
    .to(item.preview.DOM.innerElements, {
        ease: 'expo',
        startAt: {yPercent: 101},
        yPercent: 0,
        opacity: 1
    }, 'content+=0.3')
    .to(item.preview.DOM.backCtrl, {
        opacity: 1
    }, 'content')

};

When we close the preview, we’ll need to do some reverse animations:

const closeItem = item => {
    
    gsap.timeline({
        defaults: {
            duration: 1, 
            ease: 'power3.inOut'
        }
    })
    .addLabel('start', 0)
    .to(item.preview.DOM.innerElements, {
        yPercent: -101,
        opacity: 0,
    }, 'start')
    .add(() => {
        for (const line of item.preview.multiLines) {
            line.out();
        }
    }, 'start')
    
    .to(item.preview.DOM.backCtrl, {
        opacity: 0
    }, 'start')

    .to(item.preview.DOM.image, {
        y: '101%'
    }, 'start')
    .to(item.preview.DOM.imageInner, {
        y: '-101%'
    }, 'start')
    
    // animate frame element
    .to(frameEl, {
        opacity: 0,
        y: '-100%',
        onComplete: () => {
            body.classList.remove('preview-visible');
            gsap.set(frameEl, {
                opacity: 1,
                y: '0%'
            })
        }
    }, 'start')

    .addLabel('grid', 'start+=0.6')

    .to(overlayRows, {
        //ease: 'expo',
        scaleY: 0,
        onComplete: () => {
            item.preview.DOM.el.classList.remove('preview--current');
            contentEl.classList.remove('content--hidden');
        }
    }, 'grid')
};

Let’s not forget the eventListeners:

for (const item of items) {
    // Opens the item preview
    item.DOM.link.addEventListener('click', () => openItem(item));
    // Closes the item preview
    item.preview.DOM.backCtrl.addEventListener('click', () => closeItem(item));
}

This is how it all comes together:

You can do a lot of different things here, either to animate the content in or out. The key is to keep things interesting when animating in and do a swift closing animation so that the user doesn’t have to wait to long for the initial view to come back 😉 Now it’s your turn! Explore the code and try to do some other animations, timing and easings to give it another feel and see what works and what doesn’t! Hope you have fun!

Thanks for checking by ✌ Hit me up if you want to work with me!