Dependencies

Tutorial

The basic idea is: we create a list of card elements that become fixed on scroll. When a card is fixed, we scale it down and translate it to create the stack.

This tutorial is inspired by the cards animation on navigator.com.

The HTML structure is a list of card elements:

<ul class="stack-cards js-stack-cards"> <li class="stack-cards__item js-stack-cards__item"> <!-- Content here --> </li> <li class="stack-cards__item js-stack-cards__item"> <!-- Content here --> </li> <!-- additional card items here --> </ul>

We can use the sticky value of the CSS position property and apply it to the .stack-cards__item elements:

.stack-cards__item { position: sticky; top: var(--space-sm); transform-origin: center top; }

Note: In the snippet above, we are using the --space-sm spacing variable defined in the CodyHouse framework (default value is 0.75em).

Since the .stack-cards__item element has a sticky position, as soon as the offset between it and the viewport is equal to --space-sm ( top: var(--space-sm) ), the element becomes fixed. By default, each card has a translateY value equal to the gap between cards. Therefore, even though the cards have the same top value, they're offset (offset = translateY).

We have also modified the transform-origin of the card element; we'll need this to create the stacking effect while scaling down the cards.

Let's use the Intersection Observer API to detect when the card elements enter the viewport and change their transform value based on the scrolling.

We can define a StackCards object that we use to initialize the stacking effect:

var StackCards = function(element) { this.element = element; this.items = this.element.getElementsByClassName('js-stack-cards__item'); this.scrollingListener = false; this.scrolling = false; initStackCardsEffect(this); }; function initStackCardsEffect(element) { // we'll create the effect here }; var stackCards = document.getElementsByClassName('js-stack-cards'), intersectionObserverSupported = ('IntersectionObserver' in window && 'IntersectionObserverEntry' in window && 'intersectionRatio' in window.IntersectionObserverEntry.prototype), reducedMotion = Util.osHasReducedMotion(); if(stackCards.length > 0 && intersectionObserverSupported && !reducedMotion) { for(var i = 0; i < stackCards.length; i++) { new StackCards(stackCards[i]); } }

The effect will only work if the Intersection Observer API is supported ( intersectionObserverSupported === true) and if Reduces Motion is not enabled (we use the osHasReducedMotion utility function of the CodyHouse framework to check that).

The initStackCardsEffect function detects when the cards enter the viewport:

function initStackCardsEffect(element) { // use Intersection Observer to trigger animation var observer = new IntersectionObserver(stackCardsCallback.bind(element)); observer.observe(element.element); }; function stackCardsCallback(entries) { // Intersection Observer callback if(entries[0].isIntersecting) { // cards inside viewport - add scroll listener if(this.scrollingListener) return; // listener for scroll event already added stackCardsInitEvent(this); } else { // cards not inside viewport - remove scroll listener if(!this.scrollingListener) return; // listener for scroll event already removed window.removeEventListener('scroll', this.scrollingListener); this.scrollingListener = false; } }; function stackCardsInitEvent(element) { element.scrollingListener = stackCardsScrolling.bind(element); window.addEventListener('scroll', element.scrollingListener); }; function stackCardsScrolling() { if(this.scrolling) return; this.scrolling = true; window.requestAnimationFrame(animateStackCards.bind(this)); }; function animateStackCards() { // apply transform values to different card elements };

When the .js-stack-cards element is inside the viewport ( entries[0].isIntersecting == true in stackCardsCallback() function), we listen to the window scroll event and update the transform value of each cards element accordingly ( animateStackCards() function):

function animateStackCards() { var top = this.element.getBoundingClientRect().top; for(var i = 0; i < this.items.length; i++) { // cardTop/cardHeight/marginY are the css values for the card top position/height/Y offset var scrolling = this.cardTop - top - i*(this.cardHeight+this.marginY); if(scrolling > 0) { // card is fixed - we can scale it down this.items[i].setAttribute('style', 'transform: translateY('+this.marginY*i+'px) scale('+(this.cardHeight - scrolling*0.05)/this.cardHeight+');'); } } this.scrolling = false; };

In the animateStackCards() function, we check whether the card is fixed (scrolling > 0) and scale it down.

The end! 🎬