The following is a guest post by Nikolay Talanov . Nikolay wrote in to show me this demo and wanting to write about it (his first post in English, ever!). It’s an awesome demo, so of course I’m happy to pass it off to him now to take you through it. And make sure to check him out on CodePen too, he makes all kinds of other incredible demos.

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

See the Pen Wavescroll (drag background) by Nikolay Talanov ( @suez ) on CodePen .

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).

I got the idea from a random Twitter link

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

Creating Wavescroll

Later on, when I did peek, I saw their implementation was based on <canvas> , 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 JS :

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 curPage = 1; 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 staggerVal = 65; // height difference between closest slide to the active one 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$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;".ws-bg__part-" + index, changeAT, {y: y});    for (var i = leftMax; i > 0; i--) {     var d = (index - i) * waveStagger;".ws-bg__part-" + i, changeAT - d, {y: y, delay: d});   }    for (var j = rightMin; j <= bgParts; j++) {     var d = (j - index) * waveStagger;".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:

See the Pen Wavescroll (drag background) by Nikolay Talanov ( @suez ) on CodePen .

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;".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 ithere.

// 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 (, which works through <canvas> . 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!

See the Pen Wavescroll (drag background) by Nikolay Talanov ( @suez ) on CodePen and feel free to follow him on Twitter .

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