Snap Scrolling with css3 Transitions and Minimal JavascriptProgramming

Below is a tutorial for this code snippet.

I came across a pretty unique set of requirements recently where:

  • The feature has a liquid layout.
  • The carousel content had to scale accordingly.
  • The carousel had to be touch friendly and either snap to the next item, or rubber-band back into place depending on the velocity of the users swipe.

This is tricky because your typical scrolling lib is going to want to work off of pixels, but if this carousel has to perform in a liquid environment we’re going to have to use percentages for absolute positioning (-100%, 0%, 100%). The nice thing about this is css transitions can do almost all the work of animating elements in/out with one exception: when the user is dragging the “selected” item. My general rule of thumb is to never do positioning (or anything else) with inline style attributes, except in a case like this where something must be positioned with user input.

The HTML

The HTML for this is going to be relatively small. We just need a container with a class to key off of, I chose “snap-pane”. Then an unordered list with an element inside the list items to put the product image in. I chose <em> because in some way in my mind the product image is the “emphasis”, but this could be a <span> or whatever. I want to use an element I can set background image on rather then a proper <img> tag for two reasons. One, I don’t want to trigger the built-in hold gesture on mobile devices that asks you if you’d like to download the image, and two, I want to take advantage of “background-size: contain”. I know in a lot of cases this isn’t the best for rendering speeds since the browser has to do all the math to scale the image and create a new raster of it, but for this scenario I think it’s the least of all evils (otherwise you could let js do all the math and a css scale transform, but I’d rather let the css handle the view as much as possible).

<div class="snap-pane">
  <ul>
    <li><em></em></li>
    ...
  </ul>
</div>

The CSS

The “snap-pane” is the container div that all other positioning is going to be based off of. All your list items will be “position: absolute” and have transform set to “translateZ(0)”. TranslateZ will force each <li> to its own composite layer so we can take advantage of GPU acceleration. Then we’ll also set the <li> to “position: absolute” with width and height at 100%. I want to animate the elements in and out with the “left” style and percentages, so I’ll add “transition: left .25s” along with the vendor prefixed versions of the same. Having too many composite layers drawn on the page at once can cause a performance hit, so we’ll set the default display of our <li>’s to “none”.

.snap-pane {
  position: absolute;
  top: 0;
  bottom: 0;
  left: 0;
  right: 0;
}
.snap-pane li {
  display: none;
  position: absolute;
  top: 0;
  width: 100%;
  height: 100%;
  -webkit-transform: translateZ(0);
  -moz-transform: translateZ(0);
  transform: translateZ(0);
  -webkit-transition: left .25s;
  -moz-transition: left .25s;
  transition: left .25s;
}

There are going to be five possible states for our <li>’s.

  • “default”: Their basic state with no class.
  • “selected”: The <li> that is presently being viewed.
  • “left”: The <li> that would scroll in from the left next.
  • “right”: Same as left but on the right.
  • “active”: When the user is dragging/swiping the selected <li>. This is unique because we have to turn off all css transitions so it can follow the users finger quickly.
.snap-pane li.selected {
  display: block;
  left: 0%;
}

.snap-pane li.active {
  display: block;
  -webkit-transition: left 0s;
  -moz-transition: left 0s;
  transition: left 0s;
}

.snap-pane li.right {
  display: block;
  left: 100%;
}

.snap-pane li.left {
  display: block;
  left: -100%;
}

I included an <em> in each <li> so I could set up margins for the selected image. Since we’re using “background-size: contain” the image would likely come to a full bleed either on the top/bottom or left/right if it based its size on the entire space of the <li>. The <li>’s have to be width/height 100% so our percentages will work for scrolling in/out to the right and left. This lets us set margins for image. In the example I link to above I set the background-image value inline on the <em>’s for brevity.

.snap-pane li em {
  position: absolute;
  left:20px;
  right: 20px;
  top: 20px;
  bottom: 20px;
  background: center center no-repeat;
  background-size: contain;
}

The Javascript

You’ll need to include 2 js files to make this example work:

I’ve been using hammer js to manage touch events. It’s a tiny lib and it gives you a lot of goodies,  like handling the difference between different gesture types (tap, double tap, swipe, drag), or adding useful keys to the event object like delta and velocity, all while regularizing the event behavior across browsers/devices. If you’ve ever gone down the road of trying to manage gestures across iOS/android/kindle/etc. before you know it can get hairy quick.

Request animation frame polyfill just manages request animation frame with all the annoying vendor prefixes out there.

Once you’ve got those in the mix we can start building our snap pane function.

(function(document, window) {

  var _snapPaneSel = '.snap-pane', // class to key off of
      // classes for li's
      _classActive = 'active',
      _classSelected = 'selected',
      _classRight = 'right',
      _classLeft = 'left',
      _numThrowVelocity = 0.3, // the min velocity to iterate to the next image
      // create a node list of all the snap pane's on your page
      _snapPaneEls = document.querySelectorAll(_snapPaneSel),

      // pass the DOM element into the snap pane
      _snapPane = function(el) {

        var self = this;
        self.el = el;
        self.init();
      },

    _snapPane.prototype = {
      init: function() {

        var self = this;

        // create your hammer object
        self.H_el = Hammer(self.el, {
		  prevent_default: true,
		  drag_lock_to_axis: true,
		  drag_min_distance: 5,
		  swipe_velocity: 0.2
        });

        // make a node list of all the li's
        self.lis = self.el.querySelectorAll('li');
        self.lisLength = self.lis.length; // memoize lis length
        self.selectedLisNum = 0; // keeps track of which li is selected
        self.dragPosX = 0; // is used on active to place the selected li
      }
    };
}(document, window) );

The only thing you’ll need to pass into the snapPane object is the element containing the <ul>. After that we’ll set up the properties we need for the object to work. We’ll make an instance of Hammer for the parent (the element you passed in). We’ll take advantage of hammer customization by preventing default, locking the axis (we don’t need vertical drag info), and lowering the minimum distance for a drag. We do this because we are going to bind to dragStart to begin the drag animation and we want this to happen quickly. We’ll set up object properties for a nodeList of the <li>’s, which li is selected, the length of the nodeList (I just like to memoize this info), and a value for keeping track of the drag position on the object.

Next we’re going to build a function to update our classes: updateLisClasses(). Along with in our init function, we’ll also call this immediately after any update to our selectedLisNum. This function will compare the selectedLisNum against the length of out lis nodeList to determine which index of lis will sit to the left, and which will sit to the right in our updated view. Then the function will loop through the lis nodeList to assign the corresponding classes. Because of the way our css is set up, all that is required is a class update and css transitions will take care of positioning and animation.

(function(document, window) {

  var ...,

    _snapPane.prototype = {
      init: function() {

        ...

        self.updateLisClasses();
      },

      updateLisClasses: function() {
      
        var self = this,
          selectedLisNum = self.selectedLisNum,
          leftLisNum,
          rightLisNum,
          li,
          i = 0;
      
        if(selectedLisNum > 0) {
          leftLisNum = selectedLisNum - 1;
        }else {
          leftLisNum = self.lisLength -1;
        }
      
        if(selectedLisNum + 1 < self.lisLength) {
          rightLisNum = selectedLisNum + 1;
        }else {
          rightLisNum = 0;
        }
      
        for( ; i < self.lisLength; i++) {
        
          li = self.lis[i];
        
          switch(i) {
            case selectedLisNum:
              li.className = _classSelected;
              break;
            case leftLisNum:
              li.className = _classLeft;
              break;
            case rightLisNum:
              console.log('right');
              li.className = _classRight;
              break;
            default:
              li.className = '';
          }
        }
    }
  };
}(document, window) ); 

Now that we’ve got this groundwork in place we can set up our “tick” function as well as functions to begin and end the tick. The dragTick() function is going to be called on each new render frame to position the li the user is dragging accordingly. We do this on the render frame rather then on the “drag” handler because you get a performance bump from only updating the placement of the element in view once per render. We will update the dragPosX on the “drag” handler, but only update the view on render frames. The “tick” will only grab the most recent update to dragPosX when updating the view. The beginDrag() function will set the selected li’s class to “active”. If we look at the css, the main difference between active and our other classes is that the speed of our transition is 0s. This means the element can follow the users finger without a feeling of latency of delay. After this beginDrag() starts the dragTick. The endDrag() function will stop the “tick” process. Then use the velocityX to decide whether we are going to iterate through our li nodeList, or return back to the original “selected” state. We compare against velocityX so that if a user “threw” the item it will update, but if their finger/mouse was static it will rubber band back into place.

(function(document, window) {
  
  ...
  
  _snapPane.prototype = {
    ...
    
    dragTick: function() {
      
      var self = this,
          selectedLi = self.lis[self.selectedLisNum];
      
      selectedLi.setAttribute('style', 'left:' + self.dragPosX + 'px;');
      
      self.animFrame = requestAnimationFrame(function() {
        
        self.dragTick.call(self);
      });
    },
    
    beginDrag: function() {
      
      var self = this,
          selectedLi = self.lis[self.selectedLisNum];
      
      selectedLi.className = _classActive;
      
      /**
       * cancelAnimationFrame probably isn't needed here, but
       * I like to be sure I've killed the tick before starting
       * it again
       */
      cancelAnimationFrame(self.animFrame);
      
      self.dragTick();
    },
    
    endDrag: function(direction, velocityX) {
      
      var self = this,
          selectedLi = self.lis[self.selectedLisNum];
      
      cancelAnimationFrame(self.animFrame);
      
      self.dragPosX = 0;
      //selectedLi.className = _classSelected;
      
      if(velocityX > _numThrowVelocity) {
        
        if(direction === 'right') {
          
          if(self.selectedLisNum > 0) {
            self.selectedLisNum--;
          }else {
            self.selectedLisNum = self.lisLength - 1;
          }
        }else if(direction === 'left') {
          
          if(self.selectedLisNum +1 < self.lisLength) {
            self.selectedLisNum++;
          }else {
            self.selectedLisNum = 0;
          }
        }
        
        self.updateLisClasses();
      }else {
        selectedLi.className = _classSelected;
      }
      
      selectedLi.setAttribute('style', '');
    }
  };
  
  _initSnapPanes();
  
}(document, window));

Finally we’re ready to attach our event listeners to the Hammer object, using Hammers on() method. The onDragStart() handler just determines if we’re dragging horizontally, and calls beginDrag() if so. onDrag() updates the dragPosX value. And onDragEnd() just passes the direction and velocityX arguments from the custom Hammer event object to the endDrag() method.

(function(document, window) {

  var ...,

    _snapPane.prototype = {
      init: function() {

        ...

        self.H_el.on('dragstart', self.onDragStart.bind(self) );
        self.H_el.on('drag', self.onDrag.bind(self) );
        self.H_el.on('dragend', self.onDragEnd.bind(self) );
      },

      ...

      onDragStart: function(e) {

        var self = this,
            direction = e.gesture.direction;

        if(direction !== 'up' || direction !== 'down') {

          self.beginDrag();
        }
      },

      onDrag: function(e) {

        var self = this;

        self.dragPosX = e.gesture.deltaX;
      },

      onDragEnd: function(e) {

        var self = this,
            gesture = e.gesture,
            direction = gesture.direction,
            velocityX = gesture.velocityX;

        self.endDrag(direction, velocityX);
      }
    };
}(document, window) );

Now that our snapPane object is complete all that’s left to do is call it and pass it the snap pane elements from out page. I like to do this by looping through a node list or array of snap pane elements and creating them. This code could definitely be expanded to include a destroy() method or something similar incase snap pane’s are added and removed from the dom during runtime, but I wanted to focus on the single-serving problem for now.

for(var i = 0; i < _snapPaneEls.length; i++) {

  new _snapPane(_snapPaneEls[i] );
}