Horizontal scrolling single page website done right

March 7th, 2014

Single page websites are quite a hot trend in web design, with vertical scrolling being the most popular (probably because of its ease of implementation).

In this article I will summarize a strategy to implement a horizontal scrolling single page website for the purpose of achieving a certain usability goal. Too many times people implement a scrolling strategy just because “it looks cool”, or just because it’s the hot new trend, forgetting that a good design should be functional and aimed at improving the user experience. If it also looks cool, that’s a plus.

If you feel like following along with the code, you can view or download the end result here (tested on IE6, Safari 4 and Firefox 3.6). The project is also available on Github, so feel free to fork it.

Design considerations

People have grown to associate actions based on the device they are using. People using a desktop computer with a classical mice expect a navigation bar with links that when pressed allow you to switch pages instantaneously. People using a tablet or a phone need a little more care. Touching links from a navigation bar is still an expected action, but we now have at our disposal touch gestures. By using gestures the user expects a more fluid experience, so switching pages using gestures should be animated.

Switching Diagram
Figure 1 – Usability thoughts

I’m going to argue that typically, swiping up/down is more related to the act of scrolling content within the same page, whereas swiping left/right is more related to the act of switching pages. When we want to separate content in different pages, an horizontal scrolling design is superior to a vertical scrolling one because the latter cannot offer the user the perception of switching pages, at most it provides a way to aggregate all information on the same page, with the option to skip content by means of a navigation bar. With that said, sometimes aggregating information is the desired goal, so a vertical scrolling website might be just right. Just be mindful of what you are trying to achieve and choose a design appropriately. Moving on…

Layout basics

Each page will be contained within a list element that has 100% width and 100% height relative to the window size…

<!-- index.html -->
<div id="content">
<ul id="pages" class="animate">
	<li id="page1" class="page overthrow">
				Home page</li>
	<li id="page2" class="page overthrow">
				Page 2</li>

… , floated and set to 100% width and height. Don’t worry about the “animate” and “overthrow” classes, we’ll cover those soon.

/* style.css */
#content{
    height: 100%;
}

#content ul#pages, #content ul li{
    height: 100%;
    width: 100%;
    position: relative;
}

#content ul#pages li{
	float: left;
}

Now we need some help with Javascript to fix the width of each page.

$(function onLoad(){
	var scroller = HScroller.create("#pages");
        // ...
});

 

// hscroller.js
var HScroller = {
	create: function(container){
		var $container = $(container);
		var $pages = $(">li", $container);
		var $window = $(window);

		var resizeHandler = function(){
		    panel_width = $window.width();
		    $pages.width(panel_width);
		    $container.width(panel_width * pages_count);
		};

		$window.on("orientationchange resize", resizeHandler);
		resizeHandler(); // call right away first time
		// ...

At this point the browser is still using the default scrolling behavior. We need to disable global horizontal scrolling and turn on vertical scrolling for each individual page. We also need to take care of those legacy browsers that do not support scrolling properly or that implement it awkwardly (on Safari 4 for example, you can scroll a div element of fixed height using a two finger gesture. Ugh!). Luckily we can partially fill this gap by including overthrow. That’s why we added the “overthrow” class to each <li> element.

<!-- index.html -->
<script defer src="js/overthrow.min.js"></script>

 

/* style.css */
html, body{
    overflow-x: hidden; /* disable horizontal scrollbar */
}

.overthrow-enabled #content ul#pages li {
    overflow-x: hidden; /* disable horizontal scrollbar */
    overflow-y: auto; /* enable vertical scrolling */
    -webkit-overflow-scrolling: touch; /* preserve touch momentum while scrolling on WebKit browsers */
}

We get the result in Figure 2.

Diagram2
Figure 2 – Layout.

Notice that the width of each page will need to be adjusted at each window resize or orientation change event. On a side note, orientationchange is fired only on WebKit browsers.

Exploiting CSS animations

Instead of handling our horizontal scrolling animation with Javascript, we are going to exploit CSS animations. Browsers that do not support them will simply have no animations (which is still OK as long as functionality is not broken).

/* style.css */
#content ul#pages.animate{
    -webkit-transition: all .3s;
    -moz-transition: all .3s;
    -o-transition: all .3s;
    -ms-transition: all .3s;
    transition: all .3s;
}

#content ul#pages {
    transform: translate3d(0%,0,0) scale3d(1,1,1);
    -o-transform: translate3d(0%,0,0) scale3d(1,1,1);
    -ms-transform: translate3d(0%,0,0) scale3d(1,1,1);
    -moz-transform: translate3d(0%,0,0) scale3d(1,1,1);
    -webkit-transform: translate3d(0%,0,0) scale3d(1,1,1);
}

When we need pages to switch in a fluid manner, we are simply going to add the “animate” class to the main <ul> element, when we need an instantaneous switch we’ll simply remove it.

// hscroller.js
$container.setOffset = function(percent, animate){
	$container.removeClass("animate"); // <-- Do not animate
	if (animate) $container.addClass("animate"); // <-- animate

	// When transforming we are going to see a transition of 300 msecs if the <ul> element has the animate class
	if(Modernizr.csstransforms3d) {
		$container.css("transform", "translate3d("+ percent +"%,0,0) scale3d(1,1,1)");
	}else if(Modernizr.csstransforms) {
		$container.css("transform", "translate("+ percent +"%,0)");
	}else{
		var px = ((panel_width*pages_count) / 100) * percent;
		$container.css("left", px + "px");
	}
};

Note how we are going to gracefully degrade the method of moving our container by using Modernizr.

Gestures

We are going to use Hammer.js to implement our scrolling mechanism. The final code is a variation of the carousel example you can find here. I will go over the key parts of it:

// hscroller.js
var current_page = 0;
var pages_count = $pages.length;

$container.showPage = function(index, animate){
    current_page = Math.max(0, Math.min(index, pages_count - 1));
    $container.setOffset(-((100 / pages_count) * current_page), animate);
    setTimeout(function(){
        $.event.trigger("pagechanged", current_page);
    }, animate ? 300 : 0);
}
$container.next = function(){
	$container.showPage(current_page + 1, true);
};
$container.prev = function(){
	$container.showPage(current_page - 1, true);
};

showPage calculates the offset by which the main container needs to be displaced horizontally and calls the appropriate function, which in turn modifies the transform/left property of the container object. Note the pagechanged event. This will be used later to notify our routes manager that we need to update our hash history. We delay raising this event if we are animating the page change because we do not want to change the hash history until a page is fully displayed.

Hammer.js (the framework independent version) does not support browsers who do not implement the addEventListener function. For those browsers (IE8 and lower), there will be no scrolling support, the user will simply have to use the navigation links to change pages. Here’s how we can deal with this.

<!-- index.html -->
<script>
// Do not use Hammer for IE <= 8
if (window.addEventListener){
	document.write("<script defer src="js/hammer.min.js">x3C/script>");
}
</script>

 

// hscroller.js
// Bind gestures (if hammer is available)
if (typeof(Hammer) !== 'undefined'){
	var hammer = new Hammer($container[0], { drag_min_distance: 1 });

	// This is necessary to prevent the user from pinching, which will mess up our layout
	hammer.on("pinch pinchin pinchout", function(ev){
		ev.gesture.preventDefault();
	});

	hammer.on("release dragleft dragright swipeleft swiperight", function(ev){
		ev.gesture.preventDefault();

		switch(ev.type) {
			case 'dragright':
			case 'dragleft':
				// Stick to the finger
				var pane_offset = -(100 / pages_count) * current_page;
				var drag_offset = ((100 / panel_width) * ev.gesture.deltaX) / pages_count;

				// Slow down at the first and last pane
				if((current_page == 0 && ev.gesture.direction == "right") ||
				   (current_page == pages_count - 1 && ev.gesture.direction == "left")) {
					drag_offset *= .4;
				}

				$container.setOffset(drag_offset + pane_offset);
				break;
			case 'swipeleft':
				$container.next();
				ev.gesture.stopDetect();
				break;

			case 'swiperight':
				$container.prev();
				ev.gesture.stopDetect();
				break;

			case 'release':
				// More then 25% moved, navigate

				if(Math.abs(ev.gesture.deltaX) > panel_width / 4) {
					if(ev.gesture.direction == 'right') {
						$container.prev();
					}else{
						$container.next();
					}
				}else{
					$container.showPage(current_page, true);
				}
				break;
		}
	});
}

Nothing too complex here, if the hammer.min.js file is not included there will be no Hammer object, so in that case we simply do not allow scrolling (but navigation via links will still work). On a swipe gesture we switch pages and on drag we move the container appropriately so that the user can see the transition happening. If we are on the first or last page we slow down the dragging motion to signal that there are no more pages to scroll in that direction. Note how there’s not a single line of animation logic, we delegate that to CSS animations by passing true/false to our showPage method.Lastly, we want to add support for switching pages using the keyboard.

// hscroller.js
var KEYS = {
	ARROW_LEFT : 37,
	ARROW_RIGHT : 39
};

// Bind keys
$window.on("keydown", function(e){
	switch(e.which){
		case KEYS.ARROW_LEFT:
			$container.prev();
			break;
		case KEYS.ARROW_RIGHT:
			$container.next();
			break;
	}
});

Routing

At this point we have a website we can scroll with swipes, mouse drags and arrow keys, but we still need to implement a navigation menu and keep track of navigation history, so that a user can bookmark a page and use the history buttons on his browser.

We are going to use jQuery bbq to help us with the navigation history, since support for the hashchange event in legacy browsers is fragmented. First we define our menu:

/* style.css */
#header{
	position: fixed;
	top: 0;
	width: 100%;
	z-index: 1;
}

 

<!-- index.html -->
<div id="header">
		<a href="#" data-page="0">Home</a>
		<a href="#1.html" data-page="1">Page 1</a>
		<a href="#2.html" data-page="2">Page 2</a>
		<a href="#3.html" data-page="3">Page 3</a></div>

From this we can create a cache of our routes:

var routes = (function(){
	var map = {};
	$("#header a").each(function(){
		map[$(this).attr('href').replace('#', '')] = $(this).data('page');
	});
	return map;
})();

So that we get a key/value pair object that maps paths to page indexes. (” => 0, ‘1.html’ => 1, ‘2.html’ => 2, ‘3.html’ => 3). Now every time a user presses a navigation link that changes the hash history, we’ll need to switch page, following our routes:

// Handle hashchange states
var handleHashChange = (function(){
	var routes = // ...

	return function(){
		var hash = $.param.fragment(); // extract the hash fragment
		if (routes[hash] !== undefined){
			$container.showPage(routes[hash], false);
		}
	}
})();

$(window).bind('hashchange', handleHashChange);
handleHashChange(); // call first time

We call the hash change handler the first time on boot so that users that bookmarked the page will get redirected to the appropriate page. Note how we pass false to the showPage method, which disables animations making the page switch instantaneous. This goes back to our early design consideration that a user should not see animations when pressing a link.

We still need to update our hash history when a user decides to swipe between pages. This is easily done.

$(document).on("pagechanged", function(e, current_page){
	var $link = $("#header a[data-page='" + current_page + "']");
	$.bbq.pushState($link.attr('href'), 2); // 2 = params argument will completely replace current state.
});

Wrapping up

Dragging might accidentally result in text selection, which doesn’t feel right, so we disable text selection altogether…

/* style.css */
html,body{
    -webkit-user-select: none;
    -khtml-user-select: none;
    -moz-user-select: none;
    -ms-user-select: none;
    user-select: none;
}

…, disable the default action when a user touches and holds on the page…

/* style.css */
html,body{
    -webkit-touch-callout: none;
}

… and there you have it, a nice base for a functional horizontal scrolling single page website that combines nicely desktop and mobile experience!

Conclusion

With the increasing popularity of mobile devices it’s important to create websites that can leverage touch gestures and use animations to provide a more intuitive user experience. It is also important to remember that a good design should emulate expected reactions (clicking on a link brings up a page instantaneously, dragging a finger across the screen should cause an animated transition). Do not force your desktop users into a mobile first design. Blend the two experiences, as neatly as possible. Your users will appreciate it.


Follow me @pierotofy and don't forget to
  • Mmmha alcune cose si riecono a ottenere anche senza js, specialmente.nella prima parte (dove tra l’altro userei delle classi,non degli ID)
    Inoltre io disabiliterei le default gestures solo durante l’interazione,perché è una.grossa limitazione per gli utenti non poter selezionare testo
    Comunque bel lavoro!

    • pierotofy

      Potresti specificare quali parti si riescono ad ottenere anche senza js?

      • “Since in CSS we have no way to set a height based on the window size (only based on the document size) we’ll need some help with Javascript”

        Con qualche trucchetto si può


        html, body {
        /* window height */
        height: 100%;
        /* si può anche omettere */
        overflow-y: visible;
        }
        body > main {
        /* body height = window height */
        height: 100%;
        }

        • pierotofy

          Ahh, OK! Ho modificato l’articolo e il codice per evitare di usare Javascript per impostare l’altezza degli elementi. Grazie!

  • RLyn Ben

    come over to las vegas!.. its near arizona.. ehe