Go to homepage

Projects /

Stacking Cards Effect

In this tutorial, we will take a look at how to create a stacking cards effect, using the CSS sticky position and the Intersection Observer API.

Check our new component library →

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.

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).

Stacking cards effect explained

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! 🎬

Project duplicated

Project created

Globals imported

There was an error while trying to export your project. Please try again or contact us.