Creating Wavescroll

Avatar of Nikolay Talanov
Nikolay Talanov on (Updated on )

This article is a walkthough of how I made this demo of a unique way scroll through panels:

The code in this demo is (hopefully) pretty straightforward and easy-to-understand. No npm modules or ES2015 stuff here, I went with classic jQuery, SCSS, and HTML (plus a little Greensock).

Browsing through my Twitter feed the other day, I saw link to jetlag.photos. I was amazed at the effect, and thought I’d try to recreate it without peeking at their source code.

Later on, when I did peek, I saw their implementation was based on , which I’m bad at anyway, so I’m glad I went with my skill set.

Let’s start with the HTML

We’ll need a full screen sized container with some containing blocks inside for each panel. We’re namespacing everything here with ws- for “wavescroll”.

<div class="ws-pages">
  <div class="ws-bgs">
    <div class="ws-bg"></div>
    <div class="ws-bg"></div>
    <div class="ws-bg"></div>
    <div class="ws-bg"></div>
    <div class="ws-bg"></div>
  </div>
</div>

We have an additional container, because later we’ll need a place to put our text headings.

And now do basic styling with Sass

We’re using the SCSS syntax here, and it’s helping make the namespacing part very easy:

.ws {
  &-pages {
    overflow: hidden;
    position: relative;
    height: 100vh; // main container should be 100% height of the screen
  }
  &-bgs {
    position: relative;
    height: 100%;
  }
  &-bg {
    display: flex;
    height: 100%;
    background-size: cover;
    background-position: center center;
  }
}

Now we need to create background slices. Each slice of the “wave” will be it’s own <div>. Our goal is to make them look like one big background, which is centered and resized with help of background-size: cover.

Every slice will be 100% of screen width (not just the visual width of a slice) and shifted to the left with each step.

.ws-bg {
  &__part {
    overflow: hidden; // every part must show content only within it's own dimensions
    position: relative;
    height: 100%;
    cursor: grab;
    user-select: none; // significantly improves mouse-drag experience, by preventing background-image drag event
    &-inner {
      position: absolute;
      top: 0;
      // `left` property will be assigned through JavaScript
      width: 100vw; // each block takes 100% of screen width
      height: 100%;
      background-size: cover;
      background-position: center center;
    }
  }
}

Then append the background slices with JavaScript

The slices we can create dynamically with a JavaScript for loop. I originally tried to do all of this in SCSS with loops and even using ::before elements to reduce the HTML, but this way is easier and better, because you don’t need to sync variables between Sass and JavaScript:

var $wsPages = $(".ws-pages");
var bgParts = 24; // variable for amount of slices
var $parts;

function initBgs() {
  var arr = [];
  var partW = 100 / bgParts; // width of one slice in %

  for (var i = 1; i <= bgParts; i++) {
    var $part = $('<div class="ws-bg__part">'); // a slice
    var $inner = $('<div class="ws-bg__part-inner">'); // inner slice
    var innerLeft = 100 / bgParts * (1 - i); // calculating position of inner slice
    $inner.css("left", innerLeft + "vw"); // assigning `left` property for each inner slice with viewport units
    $part.append($inner);
    $part.addClass("ws-bg__part-" + i).width(partW + "%"); // adding class with specific index for each slice and assigning width in %
    arr.push($part);
  }

  $(".ws-bg").append(arr); // append array of slices
  $wsPages.addClass("s--ready"); // we'll need this class later
  $parts = $(".ws-bg__part"); // new reference to all slices
};

initBgs();

Near the end of this function we adding an s--ready class (s is for state, as controlled by JavaScript) to container. We need it to remove the static background-image from .ws-bg, which we show initially so the user sees something right away (perceived performance!).

After the slices are added, the background for original container becomes useless (and harmful, because they won’t be moving), so let’s fix that:

.ws-bg {
  .ws-pages.s--ready & {
    background: none;
  }
}
// Sass loop to set backgrounds
.ws-bg {
  @for $i from 1 through 5 {
    $img: url(../images/onepgscr-#{$i + 3}.jpg);
    &:nth-child(#{$i}) {
      background-image: $img;
      .ws-bg__part-inner {
        background-image: $img;
      }
    }
  }
}

Handling Mouse Movement

Let’s attach handlers for mouse swiping, which do the panel-changing.

var numOfPages = $(".ws-bg").length;

// save the window dimensions
var winW = $(window).width();
var winH = $(window).height();

// Not debouncing since setting variables is low cost.
$(window).on("resize", function() {
  winW = $(window).width();
  winH = $(window).height();
});

var startY = 0;
var deltaY = 0;

// Delegated mouse handler, since all the parts are appended dynamically
$(document).on("mousedown", ".ws-bg__part", function(e) { 
  startY = e.pageY; // Y position of mouse at the beginning of the swipe
  deltaY = 0; // reset variable on every swipe

  $(document).on("mousemove", mousemoveHandler); // attaching mousemove swipe handler

  $(document).on("mouseup", swipeEndHandler); // and one for swipe end
});

var mousemoveHandler = function(e) {
  var y = e.pageY; // Y mouse position during the swipe

  // with the help of the X mouse coordinate, we are getting current active slice index (the slice the mouse is currently over)
  var x = e.pageX;
  index = Math.ceil(x / winW * bgParts);

  deltaY = y - startY; // calculating difference between current and starting positions
  moveParts(deltaY, index); // moving parts in different functions, by passing variables
};

var swipeEndHandler = function() {
  // removing swipeMove and swipeEnd handlers, which were attached on swipeStart
  $(document).off("mousemove", mousemoveHandler);
  $(document).off("mouseup", swipeEndHandler);

  if (!deltaY) return; // if there was no movement on Y axis, then we don't need to do anything else

  // if "swipe distance" is bigger than half of the screen height in specific direction, then we call the function to change panels
  if (deltaY / winH >= 0.5) navigateUp();
  if (deltaY / winH <= -0.5) navigateDown();

  // even if the panel doesn't change, we still need to move all parts to their default position for the current panel
  changePages(); 
};

// Update the current page
function navigateUp() {
  if (curPage > 1) curPage--;
};

function navigateDown() {
  if (curPage < numOfPages) curPage++;
};

Adding the Wave Movement

Time to add the wave!

Each slice is positioned according to the “active” slice (the one the cursor is on), based on the “deltaY” and “index” variables. The slices move on Y axis with some delay based on how far away it is from the active slice. This “delay” is not a static number, it is a number that decreases the further away from static slice it is, even down to zero (becoming flat).

var staggerStep = 4; // each slice away from the active slice moves slightly less
var changeAT = 0.5; // animation time in seconds

function moveParts(y, index) {

  var leftMax = index - 1; // max index of slices left of active
  var rightMin = index + 1; // min index of slices right of active

  var stagLeft = 0;
  var stagRight = 0;
  var stagStepL = 0;
  var stagStepR = 0;
  var sign = (y > 0) ? -1 : 1; // direction of swipe

  movePart(".ws-bg__part-" + index, y); // move active slice

  for (var i = leftMax; i > 0; i--) { // starting loop from right to left with slices, which are on the left side from the active slice

    var step = index - i;
    var sVal = staggerVal - stagStepL;

    // the first 15 steps we are using the default stagger, then reducing it to 1
    stagStepL += (step <= 15) ? staggerStep : 1;

    // no negative movement
    if (sVal < 0) sVal = 0;

    stagLeft += sVal;
    var nextY = y + stagLeft * sign; // Y value for current step

    // if the difference in distance of the current step is more than the deltaY of the active one, then we fix the current step on the default position
    if (Math.abs(y) < Math.abs(stagLeft)) nextY = 0;
    movePart(".ws-bg__part-" + i, nextY);
  }

  // same as above, for the right side
  for (var j = rightMin; j <= bgParts; j++) {
    var step = j - index;
    var sVal = staggerVal - stagStepR;
    stagStepR += (step <= 15) ? staggerStep : 1;
    if (sVal < 0) sVal = 0;
    stagRight += sVal;
    var nextY = y + stagRight * sign;
    if (Math.abs(y) < Math.abs(stagRight)) nextY = 0;
    movePart(".ws-bg__part-" + j, nextY);
  }

};

function movePart($part, y) {
  var y = y - (curPage - 1) * winH;

  // GSAP for animation
  TweenLite.to($part, changeAT, {y: y, ease: Back.easeOut.config(4)});
};

I’m using GSAP (Greensock) for animations. Usually I don’t use animation libraries, but in this case, we need realtime animation (e.g. pausing and restarting on every mousemove event) without losing smoothness and GSAP does a great job with that.

Here’s the actual function for changing pages:

var waveStagger = 0.013; // we don't want to move all slices at the same time, so we add a 13ms stagger
// we will remove the cumulative delay from animation time, because we don't want user to wait extra time just for this interaction

function changePages() {
  var y = (curPage - 1) * winH * -1; // position, based on current page variable
  var leftMax = index - 1;
  var rightMin = index + 1;

  TweenLite.to(".ws-bg__part-" + index, changeAT, {y: y});

  for (var i = leftMax; i > 0; i--) {
    var d = (index - i) * waveStagger;
    TweenLite.to(".ws-bg__part-" + i, changeAT - d, {y: y, delay: d});
  }

  for (var j = rightMin; j <= bgParts; j++) {
    var d = (j - index) * waveStagger;
    TweenLite.to(".ws-bg__part-" + j, changeAT - d, {y: y, delay: d});
  }
};

// call the function on resize to reset pixel values. you may want to debounce this now
$(window).on("resize", function() {
  winW = $(window).width();
  winH = $(window).height();
  changePages();
});

Now we can swipe! Here’s a demo of where we’re at so far:

Paginating with the mouse wheel and arrow keys

@EliFitch helped me name it “WaveScroll”. These these UX improvements make it feel more wave-like.

// used to block scrolling so one wheel spin doesn't go through all the panels
var waveBlocked = false;

var waveStartDelay = 0.2;

// mousewheel handlers. DOMMouseScroll is required for Firefox
$(document).on("mousewheel DOMMouseScroll", function(e) {
  if (waveBlocked) return;
  if (e.originalEvent.wheelDelta > 0 || e.originalEvent.detail < 0) {
    navigateWaveUp();
  } else { 
    navigateWaveDown();
  }
});

$(document).on("keydown", function(e) {
  if (waveBlocked) return;
  if (e.which === 38) { // key up
    navigateWaveUp();
  } else if (e.which === 40) { // key down
    navigateWaveDown();
  }
});

function navigateWaveUp() {
  if (curPage === 1) return;
  curPage--;
  waveChange();
};

function navigateWaveDown() {
  if (curPage === numOfPages) return;
  curPage++;
  waveChange();
};

function waveChange() {
  waveBlocked = true; // blocking scroll waveScroll
  var y = (curPage - 1) * winH * -1;

  for (var i = 1; i <= bgParts; i++) {
    // starting animation for each vertical group of slices with staggered delay
    var d = (i - 1) * waveStagger + waveStartDelay;
    TweenLite.to(".ws-bg__part-" + i, changeAT, {y: y, delay: d});
  }

  var delay = (changeAT + waveStagger * (bgParts - 1)) * 1000; // whole animation time in ms
  setTimeout(function() {
    waveBlocked = false; // remove scrollBlock when animation is finished
  }, delay);
};

Now all the parts have been put together and we have a final demo.

Mobile Performance

After I checked this demo on my phone (Nexus 5) I found some serious performance problems during drag event. Then I remembered that usually you need to optimise any move handlers (mousemove/touchmove), because they are firing too many times in small period of time.

requestAnimationFrame was the solution. requestAnimationFrame is a special browser API created for performant animations. You can read more about it here.

// Polyfill for rAF
window.requestAnimFrame = (function() {
  return window.requestAnimationFrame ||
    window.webkitRequestAnimationFrame ||
    window.mozRequestAnimationFrame ||
    function(callback) {
      window.setTimeout(callback, 1000 / 60);
    };
})();

// Throttling function
function rafThrottle(fn) { // takes a function as parameter 
  var busy = false;
  return function() { // returning function (a closure)
    if (busy) return; // busy? go away! 
    busy = true; // hanging "busy" plate on the door 
    fn.apply(this, arguments); // calling function
    // using rAF to remove the "busy" plate, when browser is ready
    requestAnimFrame(function() {
      busy = false;
    });
  };
};

var mousemoveHandler = rafThrottle(function(e) {
  // same code as before 
});

How could we handle touch events?

You can find this code in the demo. It’s just one additional handler for touchmove, which does same stuff as mousemove. I decided to not write about this in article, because even after the rAF performance optimization, the mobile performance kinda sucks compared to the original website (jetlag.photos), which works through . Still, it’s not too bad considering it’s images in DOM elements.

Some browsers have additional problems with black lines on the edges of the slices. This problem appears because of combination of width in % and 3D transforms during the movement, which creates sub-pixel rendering issues, and shows the black background through the cracks.

That’s all!

If you have any suggestions on how I could have done anything better, I’ll be listening in the comments.