You may have seen it once on the Apple website, and since then on several websites: the full-page sliding pattern is quite popular for displaying presentations (full page schemas/animations, etc.) pages that you can flip easily in a gesture. You got it: the goal is to avoid you tedious, unprecise scrolling.

Numerous frameworks provide it to you with various qualities and costs (in code size, when it's not in money). In case you are only interested in basic page-by-page sliding in less than fifty lines of code (while waiting for a standard), or if you are interested in the technique only, this tutorial may help you.

We'll first discuss a basic JQuery implementation:

then an improved, vanilla JS, one:

To do so we have to listen to scroll events. As far as I know, and as the state of web technology as the time this article is written, this can be achieved through Javascript only. For the sake of simplicity, we'll first use JQuery shortcuts, but as we'll see this is easily transposable in pure, vanilla JS.

The structure

All we need is a sequence of block elements, and we only need to know ids:

  <div id="one">One and first</div>
<div id="two">Two</div>
<div id="three">Three and last</div>

The technique

Then we have to listen to scroll events. One first thought might be that we could listen to scroll events on each of the elements themselves, but actually the elements don't scroll themselves ; it is the viewport, your viewing window, that scrolls over them, over the body of the web page. So we might want listen to the scroll events of the window object.

Well this is tempting, and kind of works, but is probably not what we want: full-page scrollings are expected to occur on mouse wheel action, not plain scrolling using scroll bars (which will be emulated from touch events, by the way). So we'd rather let the user scroll freely, and subscribe to wheel events only:

  window.addEventListener('wheel', function(event) {
  // Do something on wheel scroll
}

Note that using this "add event" API is better than "replacing" some single handler using window.onscroll = function(e) { ... }, as it prevents from overriding some existing and desired behavior.

The algorithm

Now we have to plan what we need to provide a full-page scroll. We need:

  • the current scroll position: that's viewStart = window.scrollY
  • the beginning position of each page: that's pageStart = page.offset().top
  • the portion of a previous/next page that is enough to trigger a full page scroll (up or down). Let's say that the first tenth of a page : pageStartPortion = page.height() / 10
  • the portion of a page that stops trigerring a page jump (up or down). Otherwise you would never stop trying to jump on the page you already are. Let's say that the first eighth of a page : pageStopPortion = page.height() / 8
  • the scrolling direction. To know it, you might want to gather the latest scrolling position, then compare it to the current position, but you should actually compare it to the current position + the scrolling delta that's going to happen. Actually there is a more straightforward and reliable way to get that info : though the event provided to the callback itself, which contains an "deltaY" property. All you have to do then, is to compute scrollingForward = event.deltaY > 0 ;
  • pageEndPart = (pageStart + page.height()) - viewStart;

So, if we sum up:

  • canJumpDown = pageStartPart > pageStartPortion
  • stopJumpDown = pageStartPart > pageStopPortion
  • canJumpUp = pageEndPart > pageStartPortion
  • stopJumpUp = pageEndPart > pageStopPortion
  window.addEventListener('wheel', function(scrollEvent) {
  if (   ( scrollForward && canJumpDown && !stopJumpDown) 
      || (!scrollForward && canJumpUp   && !stopJumpUp)) {
    scrollToPage();
  }
});

The scrollToPage() function itself could be implemented in various ways, but for the sake of simplicity we'll use JQuery's animate():

  function scrollToPage() {
  $('html, body').animate({ 
    scrollTop: pageStart 
  }, 1000);  
}

where 1000 is the scroll duration in milliseconds. You could add other interpolation pattern than the default ease also.

But as you may know, JQuery's animate is asynchronous. So what if other wheel events fire during scrollToPage()? We'll handle that through a pageJump flag that will be checked to know if we are ready to page-scroll again or not. To do so we use the complete callback of the animate function, but this could be done another way (for example, by checking a common variable):

  function ScrollHandler(pageId) {      
  var page = $('#' + pageId);
  var pageStart = page.offset().top;
  var pageJump = false;

  function scrollToPage() {
    pageJump = true;
    $('html, body').animate({ 
      scrollTop: pageStart 
    }, {
      duration: 1000,
      complete: function() {
        pageJump = false;
      }
    });  
  }
  window.addEventListener('wheel', function(event) {
   var viewStart = $(window).scrollTop();
   if (!pageJump) { 
      // ...gather scrolling data
      if (  ( scrollingForward && canJumpDown && !stopJumpDown) 
         || (!scrollingForward && canJumpUp   && !stopJumpUp)) {
        scrollToPage();
      } 
  });
}

The race for smooth

Finally, subtle problem now occurs that we may have overlooked : what if the user tries to scroll while you are performing the computed scroll? As you can see, this produces shakes in the scrolling experience. And, actually, even a gentle user would have a hard time avoiding it, because of the scroll momentum.

So the key thing here is to remember to "cancel" user scroll events while you are scrolling:

  window.addEventListener('wheel', function(event) {
   var viewStart = $(window).scrollTop();
   if (!pageJump) { 
      // Compute scrolling data
      if (  ( scrollingForward && canJumpDown && !stopJumpDown) 
         || (!scrollingForward && canJumpUp   && !stopJumpUp)) {
        event.preventDefault();  // Cancel the initial scroll wheel event
        scrollToPage();
      } 
   } else {
     event.preventDefault();    // Prevent user scroll during page jump
   }    
  });

A modular approach

At first sight we might conceive a design where some process knows about all the slides and determine which one is to be scrolled toward, whether it is up or down. But this would require telling this process each time a slide is added or removed.

Instead, I opted for a design where each page is responsible for its own scrolling. A ScrollHandler is instantiated for each page id:

  function ScrollHandler(pageId) {
  var page = $('#' + pageId); 
  window.addEventListener('wheel', function(event) {
    // Do something with page
  });
}
new ScrollHandler('one'); 
new ScrollHandler('two');
new ScrollHandler('three');

Improvements

Of course there are many ways that simple code could be improved, such as:

  • supporting keyboard-controlled sliding (up/down arrow keys) ;
  • adding a bullet overview of the current selected slide ;
  • allowing to change direction during page scrolling (i.e. before it ends) ;
  • adaptation to a specific framework. I personally derived this work as an AngularJS directive, in order to benefit from the modular design through a simple page-slide directives for example:
  <div id="one" page-slide>One and first</div>
<div id="two" page-slide>Two</div>
<div id="three" page-slide>Three and last</div>

But that's another story.

A performance improvement

Among other improvements, we have some room for performance enhancements. The overall logic can remain the same as the bottleneck is clearly the animation. Another benefit will be to allow direction change in the middle of a page scroll. But any benefit comes with a cost, so we'll have to dive under the JQuery level to improve this part.

So let's forget JQuery's animate() general-purpose API, and let's write our own animate function. Basically, the idea here is to force scrolling. To do so, we'll ask the container of the page to update its scrolling position, like this:

  pagesContainer.scrollTop = newPosition; 

In the JQuery version, note that our container was referred as $('html,body'). This is a catch-all trick that handles scrolling on both the body and the html element. body should be sufficient, but some browsers like Firefox handle scrolling on the htmlelement only, and scrolling the body would have no effect in this case.

To workaround this with a more general, let's avoid making a choice. Instead, we will scroll a container div of our choice. So let's change the structure:

  <div id="scroll">
  <div id="one">One and first</div>
  <div id="two">Two</div>
  <div id="three">Three and last</div>
</div>

Here scrolled = document.getElementById('scroll'); and remember: you can only scroll into fixed/absolute (and scrolling) container:

  #scroll {
  position: absolute;
  width:  100%;
  height: 100%;
  overflow: auto;
}

Now let's look at our animate() function. As our goal is smoothness, we cannot afford less than 60 frames per second (FPS), and so 1000/60 = 16 ms at maximum per update.

  function animate() {
  runAnimation = requestAnimationFrame(animate);
  timeLapsed += 16;
  percentage = timeLapsed / duration;
  percentage = percentage > 1 ? 1 : percentage;
  position = startLocation + distance * easingPattern(percentage);
  scrolled.scrollTop = position;
  stopAnimateIfRequired();
};

Now what could be the function to update the scrolling position from a page to another one. A simple loop? It may, if you're happy with a linear scrolling experience, but most of users will prefer some kind of "easing" function (acceleration until halfway, then deceleration, typically).

Here is a formula for such a function:

  function ease(progress) {
  return progress < 0.5 ? 4 * progress * progress * progress : (progress - 1) * (2 * progress - 2) * (2 * progress - 2) + 1;
};

The trick for smoothness

Whatever the quality of your code, it may occur at the wrong time. That is, the time the browser updates its display. Ideally, you would like to ask the browser "hey, let me know when you have some spare time, and I will send updates to you about the display changes I want". That what requestAnimationFrame() does.

Avoiding multiple jumps

Sometimes you may experience sliding two pages in a single wheel gesture. This is because your gesture sent so many wheel events that not all of them are consumed when the first page has finished sliding. So other events are processed as a new page sliding request.

Should you want to avoid that, you could just wait some time before reseting the pageJump flag to false, like this:

  setTimeout(function() {
  pageJump = false;
}, 500);

Feel free to fork!


6,999 1 11