<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>
*, *:before, *:after {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

body {
  overflow: hidden;
  background: #000;
}

$numOfPages: 5;

.ws {

  &-pages {
    overflow: hidden;
    position: relative;
    height: 100vh; // main container should take 100% height of the screen
  }

  &-bgs {
    position: relative;
    height: 100%;
  }

  &-bg {
    display: flex;
    height: 100%;
    background-size: cover;
    background-position: center center;
  }
}

.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;
    }

  }
}

.ws-bg {

  .ws-pages.s--ready & { // compiles to .ws-pages.s--ready .ws-bg
    background: none;
  }
}

// don't forget to set background-images in css for .ws-bg and .ws-bg__part-inner
// in my example I'm using sass loop and setting images from codepen aws
.ws-bg {
  @for $i from 1 through 5 {
    $img: url(https://s3-us-west-2.amazonaws.com/s.cdpn.io/142996/onepgscr-#{$i + 3}.jpg);

    &:nth-child(#{$i}) {
      background-image: $img;

      .ws-bg__part-inner {
        background-image: $img;
      }
    }
  }
}
View Compiled
// frankly speaking, I can't remember how many browsers exists, which are OK with css transforms and have problems with rAF without vendor prefixes, so let's play it safe
window.requestAnimFrame = (function() {
  return window.requestAnimationFrame ||
    window.webkitRequestAnimationFrame ||
    window.mozRequestAnimationFrame ||
    function(callback){
    window.setTimeout(callback, 1000 / 60);
  };
})();

// creating simple throttle function, which will be preventing passed function from firing too fast
function rafThrottle(fn) { // taking function as parameter
  var busy = false; // creating variable, to determine if function can do another one call or not
  return function() { // returning function, which creates 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 will feel better
    requestAnimFrame(function() {
      busy = false;
    });
  };
};

$(document).ready(function() {

  var $wsPages = $(".ws-pages");
  var bgParts = 24; // variable for amount of background parts, you can play with it
  var $parts;

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

    for (var i = 1; i <= bgParts; i++) {
      var $part = $('<div class="ws-bg__part">'); // creating background part
      var $inner = $('<div class="ws-bg__part-inner">'); // and inner part
      var innerLeft = 100 / bgParts * (1 - i); // calculating position of inner block

      $inner.css("left", innerLeft + "vw"); // assigning 'left' property for each inner part with viewport units
      $part.append($inner);
      $part.addClass("ws-bg__part-" + i).width(partW + "%"); // adding class with specific index for each part and assigning width in %
      arr.push($part);
    }

    $(".ws-bg").append(arr); // append array with parts to each .ws-bg block
    $wsPages.addClass("s--ready"); // we'll need it later
    $parts = $(".ws-bg__part"); // assigning all parts to variable
  };

  initBgs();

  var curPage = 1; // variable for current page
  var numOfPages = $(".ws-bg").length; // number of pages

  // for sake of primitive optimisation we are saving window dimensions in variables
  var winW = $(window).width();
  var winH = $(window).height();

  var startY = 0;
  var deltaY = 0;

  // attaching handler with event delegation, because part is created dynamically
  $(document).on("mousedown", ".ws-bg__part", function(e) { 
    startY = e.pageY; // starting Y position of mouse at the beginning of the swipe
    deltaY = 0; // reset variable on every swipeStart

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

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

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

    // with help of X mouse coordinate we are getting current active part index, over which currently positioned mouse cursor
    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 were 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 calling function which changes pages
    if (deltaY / winH >= 0.5) navigateUp();
    if (deltaY / winH <= -0.5) navigateDown();

    // moving all parts
    // even if page didn't changed, we still need to move all parts on their default position for this page
    changePages(); 
  };

  // super simple functions which just changes variable of current page
  function navigateUp() {
    if (curPage > 1) curPage--;
  };

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

  var staggerVal = 65; // starting value of height difference between closest parts to active one
  var staggerStep = 4; // value, with which "ladder steps" becoming smaller, as they distancing from active part with every step

  var changeAT = 0.5; // animation time in seconds

  function moveParts(y, index) {
    // y - deltaY, index - index of current active element

    var leftMax = index - 1; // max index of parts which are left from active
    var rightMin = index + 1; // min index of parts from right

    // variables for accumulated value of vertical distance between active part and current part in loop
    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); // moving active part

    for (var i = leftMax; i > 0; i--) { // starting loop "from right to left" with parts, which are on left side from active
      var step = index - i; // how far is current part from active one

      var sVal = staggerVal - stagStepL;

      // for first 15 steps we are using default stagger, then reducing it to one
      // magic numbers are bad. Don't do shit like this
      stagStepL += (step <= 15) ? staggerStep : 1;

      // we don't want to see steps going in reversed direction at bigger distance from active element
      if (sVal < 0) sVal = 0;
      stagLeft += sVal; // distance difference of current step adding to accumulative value
      var nextY = y + stagLeft * sign; // Y value for current step

      // if distance difference of current step more than deltaY of active one, then we are fixating current step on default position
      if (Math.abs(y) < Math.abs(stagLeft)) nextY = 0;
      movePart(".ws-bg__part-" + i, nextY); // moving this specific part
    }

    // same goes here as in loop above, just things, which are from right of active part moving from left to right
    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; // changing Y value according to current page

    // we are using GSAP for animation
    // usage is very simple:
    // TweenLite.to(%selector%, %animation time in seconds%, {%properties for animation and additional options%}
    // we are using Back easing to create bounce effect. The bounce easing itself looks creepy with fast movement in this demo
    TweenLite.to($part, changeAT, {y: y, ease: Back.easeOut.config(4)});
  };

  var waveStagger = 0.013; // we don't want to move all parts at same time and adding 13ms stagger delay
  // but we will be substracting accumulative delay from animation time, because we don't want user to wait extra time, just because he pulled background parts on one of the sides

  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});
    }
  };

  // and we are adding this function call into resize handler, because we are using pixel dimensions.
  // in real project you usually want to use debounce function with resize handler handler, to reduce amount of pain for browser during resizing
  $(window).on("resize", function() {
    winW = $(window).width();
    winH = $(window).height();
    changePages();
  });

  // creating a blocking variable, because we don't want user to scroll all pages with one wheel spin
  var waveBlocked = false;

  var waveStartDelay = 0.2; // starting wavescroll delay

  // adding 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() {
    // unline default navigate function, in these we are not doing anything if user want to scroll in overflow
    // otherwise we'll be activating scrollBlock for nothing and making user wait until it's unblocked for no reason
    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 parts with stagger delay, besides static one
      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; // removing scrollBlock when animation is finished
      // please note that we can't use GSAP onComplete, because it will fire on each loop step
    }, delay);
  };

});

External CSS

This Pen doesn't use any external CSS resources.

External JavaScript

  1. https://cdnjs.cloudflare.com/ajax/libs/jquery/2.1.3/jquery.min.js
  2. https://cdnjs.cloudflare.com/ajax/libs/gsap/1.18.2/TweenLite.min.js
  3. https://cdnjs.cloudflare.com/ajax/libs/gsap/1.18.2/plugins/CSSPlugin.min.js
  4. https://cdnjs.cloudflare.com/ajax/libs/gsap/1.18.2/easing/EasePack.min.js