Nothing is as much fun as seeing elements on screen interacting with your own scroll input... Okay scrap that, there are a million things as much fun or even more, but nontheless the scroll event is a great tool which opens up a world of possibilities for cool effects and great annoyances, like animations. Now is it always a good idea to make everything animate while a user is scrolling? Not at all. But is it cool? You're damn right! More than enough reason for me to make something that'll animate all over the place!

Today we'll make a list of posts from which the first and last element in the viewport 'fold' in and out. To make this, we'll use some vanilla js for the viewport detection and Greensock's TweenLite for the animations.

tip: If you use codepen and want to use Greensock animations, you can add the TweenMax library to your pen by going to your pen settings->javascript->quick-add. It contains TweenLite, TweenMax, timelineLite and some more stuff. Just about everything we need.

The markup and CSS

Now first of all, let's have a look at what we're going to work with. I write my markup in Jade, but in the embedded Pens you can switch to HTML with the button on the bottom right corner.

The markup only consists out of a container with the class container and a number of articles with the class excerpt. You might notice that these articles all contain an inner container; we'll get back to that later!

I've used SCSS for the styles and imported the Sass library 'breakpoint' to easily add some mediaqueries. The styles are pretty arbitrary, though it might be recommendable (not necessary) to use items with a set height.

The basic JS

Now check out the bare Javascript:

  var AnimateExcerpts = function() {
  // Declare variables
  var 
    excerpts,
    excerptsInner

  // Everything that needs to run on initializing the script
  var _init = function() {
    // assigning variables
    excerpts = document.querySelectorAll('.excerpt');
    excerpts = Array.prototype.slice.call(excerpts);
    excerptsInner = document.querySelectorAll('.excerpt__inner');
    excerptsInner = Array.prototype.slice.call(excerptsInner);

    _addEventHandlers();
  }
  // Here we will add our eventhandlers
  var _addEventHandlers = function() {   
  } 

  return {
    init: _init
  }
}();
// initialize 
AnimateExcerpts.init();

We've made a function that does the following:

  • Declares variables
  • Assigns variables and adds functions that need to run on Init
  • Seperates our eventhandlers
  • Returns the _init function.

The variables are assigned twice because document.querySelectorAll doesn't return an array but an object. To turn this object into a loopable array, you can use Array.prototype.slice.call(arrayName).

Detecting elements in viewport

If you're a bit like me and have no feeling for math or logic whatsoever, it proves to be rewarding to get out your pad and pencil to sketch out what and when to animate here. First, let us write out the two major conditions under which we want to animate an element. I'll call these conditional 1 and conditional 2.

  1. Each element scrolling out of the top of the viewport should animate from the moment the top of said element scrolls out of the viewport untill the moment the bottom of said element hits the top of the viewport.

  2. At the bottom we want the element that scrolls in from the bottom of the viewport to animate from that moment untill the bottom is equal to the bottom of the viewport. On top of that we want all items that don't fall under these conditions to behave normally.

Damn, I'm confused from just typing that.

Detect elements positions

To detect an elements position we can use element.getBoundingClientRect() which returns an object with the elements position relative to the top and left of the viewport. So if we create a function that iterates over each excerpt element and get the positions for each, we can also determine the position relative to the viewport for each.

After that we'll tie our function to a scroll event with addEventListener so that we get the updated positions as we scroll. We'll call our new function _inview

  var _inView = function() {
  excerpts.forEach(function(element, index) { 
    var pos               = element.getBoundingClientRect();
    var elBottomFromTop   = pos.bottom;
    var elTopFromTop      = pos.top;
  })
}

Here we loop through the excerpts array with the forEach method (you can also use an old fashioned loop if you like), get the getBoundingClientRect from each element and store it in the variable pos. From that variable we get each elements height, position from the bottom of the element relative to the viewports top and the position of the top of the element relative to the viewports top.

As we also need to know to know the elements top and bottom position relative to the viewports bottom, we'll add two extra variables which calculate these positions based on the positions relative to the top with the the viewport height.

  var elTopFromBottom     = pos.top - window.innerHeight;
var elBottomFromBottom  = pos.bottom - window.innerHeight;

There, that's all we need to detect which element is where.

You can now add a new line to your _addEventHandler function where you add an eventlistener that fires the _inView function on scroll.

  var _addEventHandlers = function() {   
    window.addEventListener('scroll', _inview, false);  
}

If you add console.log(index,elTopFromTop,elBottomFromTop) you will get these positions from each element, where index gives you the order.

Adding conditionals

First, let's start with a conditional that only returns true in case of story 1.

  if( elTopFromTop <= 0 && elBottomFromTop >= 0 ) { 
    console.log(index, elTopFromTop, elBottomFromTop);
}

So this returns true on the following conditions: If the elements top is equal to or smaller than the viewport top (which is 0) and the bottom of the element is bigger than or equal to the viewport top (again, 0)

If all is well, now you only get response of the element currently scrolling out from the top of the viewport.

Second, we need a conditional statement that returns true when the top of the item scrolling in has hit the bottom of the viewport untill the bottom of the element has hit the bottom of the viewport:

  if( elTopFromTop <= 0 && elBottomFromTop >= 0 ) { 

}
else if( elTopFromBottom <= 0 && elBottomFromBottom >= 0 ) {
    console.log(index, elTopFromBottom, elBottomFromBottom);
}

Now we've got everything to start animating the elements with Greensock!

Animating with Greensock

Let's start with preparing our elements for the animation. We want to set a couple of properties to each element and we'll do that with Tweenlite. We create a new function called _prepare.

  var _prepare = function() {
    TweenLite.set(excerptsInner, {transformPerspective: 900})
}

We'll call this function in our _init() script so it sets the properties as soon as possible. The TransformPerspective property we set here gives each item its own vantage point when we rotate the X axis. The lower the value, the bigger the 3d effect. We're going with a relative high value as we don't want the effect to be too obtrusive.

Next up, we'll add another variable to our forEach function, which retrieves the .excerpt__inner wrapper.

We're going to animate the inner wrap instead of the element itself because transforming the element changes its position relative to the viewport. This makes it slightly more difficult to detect the right offsets.

If you add that to your function and remove all the console.log() calls, you should have something like this:

  var _inView = function() {
  excerpts.forEach(function(element, index) { 
    var inner               = element.children[0];      
    var pos                 = element.getBoundingClientRect();
    var elBottomFromTop     = pos.bottom;
    var elTopFromTop        = pos.top;
    var elTopFromBottom     = pos.top - window.innerHeight;
    var elBottomFromBottom  = pos.bottom - window.innerHeight;

    if( elTopFromTop <= 0 && elBottomFromTop >= 0 ) { 

    } 
    else if( elTopFromBottom <= 0 && elBottomFromBottom >= 0 ) {

    }
  });
}

Now before we start the timeline, we need to have a value that changes on scroll to controll the rotation animation we want to make. Say we'd like to change the rotation from 0 to 50 during the height of the scroll, we can divide the top offset of the element through the element height. This way we've got an incremental counter which we can control any way we like. As this counts up, we add this through our conditional for the top elements:

  if( elTopFromTop <= 0 && elBottomFromTop >= 0 ) { 
    var transformOffset   = (elTopFromTop / elHeight) * -50;
  console.log(index, transformOffset);
} 

If all is well you'll see a counter going from 0 to 50 when scrolling the element out of the viewport.

And now we're finally going to animate some stuff! We'll be using TweenLite to animate things. We'll add a new Tween with the following properties:

  if( elTopFromTop <= 0 && elBottomFromTop >= 0 ) { 
    var transformOffset   = (elTopFromTop / elHeight) * -50;
  /* Create new Tweenlite which animates the `inner` element we've defined before */
  TweenLite.to(inner, 0.3, {
    // rotate with transformOffset
    rotationX: transformOffset,
    // set transform origin to center-bottom
    transformOrigin: '50% 100%', 
    // Add a nice ease
    ease:Power2.easeOut
  });
} 

As the transformOffset value gets updated when we scroll the element out of the viewport, the TweenLite function is continously updated as well, updating the matrix3d css property on the element.

Now we've got our animating in and out at the top handled, we should do the same for the elements scrolling into the viewport at the bottom.

  else if( elTopFromBottom <= 0 && elBottomFromBottom >= 0 ) {
  var transformOffset   =   (elBottomFromBottom / elHeight) * -50;
  TweenLite.to(inner, 0.3, {
    rotationX: transformOffset,
    transformOrigin: '50% 0%', 
    ease:Power2.easeOut
  });
}

As the counter should be reversed, we use a different calculation for the offset. We also use a different transform-origin property. The Y-axis is set to 0% to ensure the element transforms from the right side.

We're almost there!

With just the if and else if statements we have now, not everything works smoothly. If you scroll fast, you'll see that the elements that have already been animated won't return back into their original position exactly. This is because the scroll event can't fire as fast as you scroll.

To prevent this, we'll make sure that every element not animating snaps back to the original position. We do this with one final addition to our if / else if and a last TweenLite.

  TweenLite.to(inner, 0.15, {rotationX:0, ease:Linear.easeNone})

To ensure any item which is partially into view on page load is in its right position. We add the _inview function to the _init function so it fires once upon load.

Responsive?

Yeah sure! Just add an extra eventListener to your _addEventHandlers function that listens to resize, like this:

  window.addEventListener('resize', _inView, false);

You might want to throttle / debounce that though!

The result

And that's it! See the final result here below. This easy way can be used to animate just about anything in and outside of the viewport. Now beware that updating elements on scroll is quite heavy, and it's adviced to throttle wherever possible.