Dependencies
- CodyFrame (CodyHouse front-end framework)
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! 🎬