Creating a Shrinking Logo Animation with Intersection Observers

Published

The Intersection Observer API can be used for many great things including lazy loading images, triggering animations, and even setting progress indicators on navigation menus.

When thinking of ways to spruce up my blog, I became curious if Intersection Observers could handle a shrinking logo effect on scroll.

I tried it out, and while the resulting animation could be a bit rigid at times, I was pleased with how things turned out.

How it works

The main pieces of the solution are:

  1. Logo Element – the logo image being resized on scroll.
  2. Scroll Tracker Element – an invisible <div> sitting at the top of the page that is observed by the Intersection Observer. As this <div> intersects the top of the browser viewport, the size of the logo is adjusted.
  3. Intersection Observer – multiple thresholds on the Intersection Observer trigger a callback function at different points while scrolling “through” the Scroll Tracker Element, allowing for the gradual adjustment of logo size.

The Logo Element

The Logo Element (#logo-element below) is designed so that its size is relative to its parent container (#logo-container). The container element’s height and width can be set via responsive CSS rules, while the JavaScript that adjusts the Logo Element size remains agnostic, and sets the logo size based on percentages.

Both the Logo Element and its parent container sit inside of a (presumably) sticky header element.

header {
  position: -webkit-sticky;
  position: sticky;
  top: 0;

  #logo-container {
    height: 60px; // set responsively via CSS media query
    width: 60px; // set responsively via CSS media query
    display: flex;
    justify-content: center;
    align-items: center;
  
    #logo-element {
      background: url('../../img/topnav/tcallsen-avatar-min.jpg');
      background-size: cover;
      width: 100%; // set as relative percentage via JavaScript
      height: 100%; // set as relative percentage via JavaScript
      border-radius: 50%;
      transition: height .1s, width .1s;
    }
  
  }

}

The Scroll Tracker Element

The Scroll Tracker Element is placed at the top left of the page (highlighted in red in the video below). Notice the logo size change as the top of the browser viewport scrolls through the Scroll Tracker Element.

The Scroll Tracker Element is given absolute positioning so its location does not change while scrolling. The element styles are also tweaked based on:

  • When the logo shrinking animation should start (by adjusting the top CSS rule).
  • How long the animation should last (by adjusting the height).
#scroll-tracker-element {
  position: absolute;
  pointer-events: none;
  top: 6px; // sets the scroll location where the animation begins
  height: 36px; // controls the animation length (a taller div means more scrolling is required to finish the animation)
  width: 20px; 
  // rules below removed after development
  background: red; 
  z-index: 75; 
}

The Intersection Observer

The Intersection Observer is configured to listen for multiple intersection thresholds. I experimented with different increments and found this to be the best performance in my case, however other situations may differ.

// configure intersection observer with multiple thresholds
//  - the handleObserverIntersection function will be fired at each threshold defined below
const observer = new IntersectionObserver(handleObserverIntersection, {
  threshold: [0, 0.25, 0.5, 0.75, 1]
});

const scrollTrackerElement = document.getElementById('scroll-tracker-element')
observer.observe(scrollTrackerElement)

Next the glue of the operation: the Intersection Observer callback function.

The callback function is fired each time a threshold is reached on an observed element (The Scroll Tracked Element in this case). The function receives an Entry object; the intersectionRatio property of this object specifies the level of intersection experienced by the element.

At the top of the page, the intersectionRatio starts at 1, denoting that the Scroll Tracker Element is in view, and hasn’t reached the top of the browser viewport yet. While scrolling down, the intersectionRatio gradually reduces to 0 as the element intersects top of the browser viewport, and is eventually scrolled out of view.

Inside the callback function, the intersectionRatio property is converted into a rounded percentage, which is then applied as the height and width of the Logo Element.

function handleObserverIntersection(entries) {
  
  entries.forEach(entry => {
    
    // entry.intersection ratio decreases from 
    //  1 to 0 when scrolling down through 
    //  #scroll-tracker-element div
    
    // convert intersectionRatio into shrinkage amount so
    //  that intersectionRatio of 0 = 60% original size
    const newLogoPercentage = 100 - ((1 - entry.intersectionRatio) * 40)
    const roundedLogoPercentage = Math.ceil(newLogoPercentage / 10) * 10 // round to nearest tenth

    // set new size to logo image
    const logoElement = document.getElementById('logo-element') // best to save reference to this element so DOM query is only required once
    logoElement.style.width = roundedLogoPercentage + '%'
    logoElement.style.height = roundedLogoPercentage + '%'

  })

}

The Intersection Observer works regardless of scroll direction, so the logo is shrunk when scrolling down the page, and then enlarged when scrolling back to the top.

Handling Mid-Page Loads

I soon realized the logo flickered whenever a post was loaded mid-page. The best example of this is when using the back button in the browser; the previous page is loaded and automatically scrolled to the point where you left off.

To alleviate this issue, I tried adjusting the CSS for the logo container to be hidden at page load, and then revealed once the JavaScript executed.

I had to go one set further since my logo had a CSS transition set on the height and width properties. The transition would still play out when the size and visibility of the logo were set in the same block of synchronous JavaScript code.

To remedy this, I modified the callback function to:

function handleObserverIntersection(entries) {
  
  entries.forEach(entry => {
    
    // 1) set logo size (same logic as above but condensed without comments and spaces)
    const newLogoPercentage = Math.ceil( 100 - ((1 - entry.intersectionRatio) * 40) )
    const logoElement = document.getElementById('logo-element') // best to save reference to this element so DOM query is only required once
    logoElement.style.width = roundedLogoPercentage + '%'
    logoElement.style.height = roundedLogoPercentage + '%'
    
    if (!isInitialized) { // check to ensure this logic only executes once
      isInitialized = true 

      // 2) set logo container to visible
      const logoContainer = document.getElementById('logo-container')
      logoContainer.style.display = "block"
      
      // 3) enable logo transition animation
      if (entry.intersectionRatio > 0) {
        // loaded at top of page - can enable animation immediately
        logoElement.classList.add('enable-logo-animation')
      } else {
        // loaded mid page - must enable animation asynchronously with delay so logo 
        setTimeout(() => { logoElement.classList.add('enable-logo-animation') }, 250)
      }
    
    }
  })

}

The key modification is the setTimeout() logic on line 23, which adds the transition enabling CSS class asynchronously. This allows the logo size to be set on the first pass, and then the CSS transition to be applied “later” once the change in size has already occurred.

Merging in the following CSS rules completed the the mid-page load fix, and I was home free!

#logo-container{
  display: none; // container hidden at page load and displayed via JavaScript
  
  #logo-element {
    // remove the transition rule in CSS shown in The Logo Element section above
    // transition: height .1s, width .1s;
    
    &.enable-logo-animation{
      // enable logo transition animation only after class is present
      transition: height .1s, width .1s;
    }

  }

}

Comments

Loading comments..

No responses yet

Leave a Reply

Your email address will not be published. Required fields are marked *