A horizontal scrolling navigation pattern for touch and mouse with moving current indicator
This is a practical post. A step-by-step of building up a navigation solution. I tried to leave in all the mistakes I made along the way to save you from my own folly; as such it’s pretty long. Sorry about that!
These days, thanks to the ubiquity of touch devices, users are generally familiar with horizontal scrolling panels. They are an effective way of minimising vertical space while still allowing plentiful content.
However, for mouse input, the pattern doesn’t work as well. By default, there is no direct way to click and drag the content so users can get to any elements out of the visible area. You could of course leave the scrollbar visible but despite being able to bespoke the styling, in most situations I still find it quite ugly.
If you choose to hide the scrollbars where you can, it’s still possible to scroll horizontal panels with mouse input by holding down a modifier key (shift on a Mac for example) while using a mouse wheel. However, this is niche/power user functionality β certainly not something we can rely on.
So, the task in this post is to create a simple scrollable panel for touch, augmented with click and drag functionality for mouse input, along with direction overflow indicators.
Here’s what we will end up with: http://benfrain.com/playground/scroller.html. And we will be building to that through the collection of Codepen steps here: http://codepen.io/collection/npZQLd/:
See the Pen zZZLaP by Ben Frain (@benfrain) on CodePen.
So, how do we get there? Let’s start with some basic HTML:
Our navigation HTML structure could look like this:
<nav class="pn-ProductNav">
<div class="pn-ProductNav_Contents">
<a href="#" class="pn-ProductNav_Link" aria-selected="true">Chairs</a>
<a href="#" class="pn-ProductNav_Link">Tables</a>
<a href="#" class="pn-ProductNav_Link">Cookware</a>
<a href="#" class="pn-ProductNav_Link">Beds</a>
<!-- more links -->
</div>
</nav>
I’m using ECSS naming conventions here and following the nesting authoring pattern to provide a single source of truth for each selector (sorry Thierry, I know you’d rather see vanilla CSS):
.pn-ProductNav {
/* Make this scrollable when needed */
overflow-x: auto;
/* We don't want vertical scrolling */
overflow-y: hidden;
/* Make an auto-hiding scroller for the 3 people using a IE */
-ms-overflow-style: -ms-autohiding-scrollbar;
/* For WebKit implementations, provide inertia scrolling */
-webkit-overflow-scrolling: touch;
/* We don't want internal inline elements to wrap */
white-space: nowrap;
/* Remove the default scrollbar for WebKit implementations */
&::-webkit-scrollbar {
display: none;
}
}
The default scrollbars have been hidden where possible, although STILL, 16 years on in Firefox, there is no way to do this without significant hackery.
That gets us this:
See the Pen WpRgZd by Ben Frain (@benfrain) on CodePen.
I’ve added some more basic styling to make it a little more visually appealing, and set the colour of the selected link (using ARIA attributes) but that doesn’t effect the principles of how it works.
If you look at http://codepen.io/benfrain/full/WpRgZd on a hand held this should happily do the horizontal scrolling thing. OK, great, that was the easy part.
Indicating overflow
Now, unless I missed the memo, it’s not possible to know what input a user has ahead of time (for all you wanted to know and more, check out https://patrickhlauke.github.io/getting-touchy-presentation/) so it’s best to implement as many features universally as we can. If we are ditching the visible scrollbars β which provided indication to the user of an overflowing area, we better have something else to solve that issue in a more visually appealing manner.
So, I got ahead of myself. Let’s do the right thing here first. On our HTML element, by default, is a no-js
class.
<DOCTYPE! html>
<html class="no-js">
Let’s use JavaScript to amend this class to simply js
when JS is present. Then we can choose to only hide the scrollbars if JS is present:
document.documentElement.classList.remove("no-js");
document.documentElement.classList.add("js");
Our revised CSS:
.pn-ProductNav {
/* Make this scrollable when needed */
overflow-x: auto;
/* We don't want vertical scrolling */
overflow-y: hidden;
/* For WebKit implementations, provide inertia scrolling */
-webkit-overflow-scrolling: touch;
/* We don't want internal inline elements to wrap */
white-space: nowrap;
/* If JS present, let's hide the default scrollbar */
.js & {
/* Make an auto-hiding scroller for the 3 people using a IE */
-ms-overflow-style: -ms-autohiding-scrollbar;
/* Remove the default scrollbar for WebKit implementations */
&::-webkit-scrollbar {
display: none;
}
}
}
OK, we can feel a tiny bit better inside now. Let’s also add a function that can determine if our content is overflowing its container and add a data attribute to communicate that state in the DOM.
function determineOverflow(content, container) {
var containerMetrics = container.getBoundingClientRect();
var containerMetricsRight = Math.floor(containerMetrics.right);
var containerMetricsLeft = Math.floor(containerMetrics.left);
var contentMetrics = content.getBoundingClientRect();
var contentMetricsRight = Math.floor(contentMetrics.right);
var contentMetricsLeft = Math.floor(contentMetrics.left);
if (containerMetricsLeft > contentMetricsLeft && containerMetricsRight < contentMetricsRight) {
return "both";
} else if (contentMetricsLeft < containerMetricsLeft) {
return "left";
} else if (contentMetricsRight > containerMetricsRight) {
return "right";
} else {
return "none";
}
}
This function measures the right and left position of the first parameter, content
(it should be a DOM element) and the second parameter, container
(another DOM element) and returns to us whether the content is overflowing to the right, left, both sides or not at all.
Let’s feed the function our existing container and content. I’m adding in IDs to the relevant DOM elements here for simplicity but you could obviously grab them however you like. So the HTML now has IDs in the relevant places:
<nav id="pnProductNav" class="pn-ProductNav">
<div id="pnProductNavContents" class="pn-ProductNav_Contents">
<a href="#" class="pn-ProductNav_Link" aria-selected="true">Chairs</a>
<!-- more -->
</div>
</nav>
And I’m grabbing them in JS like this:
var pnProductNav = document.getElementById("pnProductNav");
var pnProductNavContents = document.getElementById("pnProductNavContents");
And we feed them to our determineOverflow
function like this:
pnProductNav.setAttribute("data-overflowing", determineOverflow(pnProductNavContents, pnProductNav));
And then in the DOM, by default (unless you have an enormous screen), we should see data-overflowing="right"
on our product nav.
Except we don’t.
It’s currently returning data-overflowing="none"
. What gives?
Well, a trip into the dev tools reveals that even though the content inside pn-ProductNav_Contents
is leading off the page, the computed width of pn-ProductNav_Contents
is actually the same width as pn-ProductNav
, its wrapper. So, we need some way to make the container the same width as it’s content. We can do this with intrinsic sizing in CSS by applying width: min-content
. However, IE doesn’t support intrinsic and extrinsic sizing so we need to go ‘Old Skool’ and break out the float. We will change the content to be a block that is floated left, to create a new block formatting context for its contents.
.pn-ProductNav_Contents {
float: left;
}
With that done, our content is far wider than the container. If you look at the attribute of the wrapper in this example, you can see we have data-overflowing="right"
on the pn-ProductNav
element.
See the Pen NpdOzm by Ben Frain (@benfrain) on CodePen.
Overflow indicators
Now the DOM can tell us what’s overflowing we need it to update as the content is scrolled. Let’s listen to scroll
event to do this but as scroll can fire A LOT, we will adapt the example from HTML5 Rocks to perform the action behind request animation frame:
// Handle the scroll of the horizontal container
var last_known_scroll_position = 0;
var ticking = false;
function doSomething(scroll_pos) {
pnProductNav.setAttribute("data-overflowing", determineOverflow(pnProductNavContents, pnProductNav));
}
pnProductNav.addEventListener("scroll", function() {
last_known_scroll_position = window.scrollY;
if (!ticking) {
window.requestAnimationFrame(function() {
doSomething(last_known_scroll_position);
ticking = false;
});
}
ticking = true;
});
If you scroll the content now, you can see the attribute gets updated based upon whether or not the content is overflowing on the left, right, both or none. Groovy. Now how about showing that stuff visually?
Let’s add a couple of elements to serve as our indicators. You could have the indicators as background-images, icon-fonts, whatever. I’ve gone for inline SVGs:
<nav id="pnProductNav" class="pn-ProductNav">
<div id="pnProductNavContents" class="pn-ProductNav_Contents">
<!-- Links here -->
</div>
<button class="pn-Advancer pn-Advancer_Left" type="button">
<svg class="pn-Advancer_Icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 551 1024"><path d="M445.44 38.183L-2.53 512l447.97 473.817 85.857-81.173-409.6-433.23v81.172l409.6-433.23L445.44 38.18z"/></svg>
</button>
<button class="pn-Advancer pn-Advancer_Right" type="button">
<svg class="pn-Advancer_Icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 551 1024"><path d="M105.56 985.817L553.53 512 105.56 38.183l-85.857 81.173 409.6 433.23v-81.172l-409.6 433.23 85.856 81.174z"/></svg>
</button>
</nav>
I’m using button element so I have some serious ‘undoing’ to do stylistically. Here’s the CSS for the buttons and the SVGs within:
.pn-Advancer {
/* Reset the button */
appearance: none;
background: transparent;
padding: 0;
border: 0;
&:focus {
outline: 0;
}
/* Now style it as needed */
position: absolute;
top: 0;
bottom: 0;
}
.pn-Advancer_Left {
left: 0;
}
.pn-Advancer_Right {
right: 0;
}
.pn-Advancer_Icon {
width: 20px;
height: 44px;
fill: #bbb;
}
position: relative
to pn-ProductNav
so they are located relevant to it (and not the nearest non-statically positioned container).
OK, that should do it. Let’s scroll it and see how it looks:
See the Pen yMgZme by Ben Frain (@benfrain) on CodePen.
Oh crap! Can you see how the arrow travels as we scroll. That’s not what I wanted. But it makes (some) sense. The absolutely positioned elements are positioned inside their container, but then when we scroll, the whole element is scrolling. Don’t worry, we can fix this with a little extra structure. We can wrap our nav in a containing element which will provide the positioning context for our buttons.
Out revised HTML:
<div class="pn-ProductNav_Wrapper">
<nav id="pnProductNav" class="pn-ProductNav">
<div id="pnProductNavContents" class="pn-ProductNav_Contents">
<!-- Links -->
</div>
</nav>
<button class="pn-Advancer pn-Advancer_Left" type="button"><!--button SVG --></button>
<button class="pn-Advancer pn-Advancer_Right" type="button"><!--button SVG --></button>
</div>
We then move our positioning to our new element:
.pn-ProductNav_Wrapper {
position: relative;
}
Now everything is where it needs to be, let’s show and hide those indicators depending upon where the content is overflowing. We can set them to be have no opacity by default and then transition their appearance as needed:
.pn-Advancer_Left {
left: 0;
[data-overflowing="both"] ~ &,
[data-overflowing="left"] ~ & {
opacity: 1;
}
}
.pn-Advancer_Right {
right: 0;
[data-overflowing="both"] ~ &,
[data-overflowing="right"] ~ & {
opacity: 1;
}
}
Now, regardless of input type, as you scroll the panel, you get some indication of where the panel overflows. You can see the current state of our demo here:
See the Pen oZBOBv by Ben Frain (@benfrain) on CodePen.
Making the panel advance on click
So our indicators are now doing the job a visible scrollbar does; we are indicating to the user there is content off one side or the other. Let’s add some functionality to advance the panel in either direction if the user clicks on them.
First we will need a few settings, their use will make more sense shortly:
var SETTINGS = {
navBarTravelling: false,
navBarDirection: = "",
navBarTravelDistance: 150
}
Let’s grab our two buttons (again, I’ve added IDs for easy access):
// Our advancer buttons
var pnAdvancerLeft = document.getElementById("pnAdvancerLeft");
var pnAdvancerRight = document.getElementById("pnAdvancerRight");
Now, the idea is that when a use clicks a button, we are going to move the scroll panel inside the container using translateX
. Then, once the move has ended, we remove the transform but apply the same distance to the scrollLeft property on the panel (scrollLeft being the distance, in px that the panel has been scrolled). That should provide a smooth way of advancing the panel in either direction.
The settings we set as an object provide a means to prevent additional clicks whilst we are moving our panel and also allow us to set different default move amounts.
We also want to ‘gobble up’ any little bits at the end, so if a user is fairly close to the end, we don’t want the standard travel amount that would leave, say 10px, of space to scroll. In that scenario, we just transition the whole distance to the end. That will make more sense when you try it!
There’s a big blob of code coming up here but it is commented so a few reads through and hopefully it’ll make some sense. Note, I don’t profess to be a JS ninja so there are likely better ways to achieve this (and I will happily accept the schooling).
pnAdvancerLeft.addEventListener("click", function() {
// If in the middle of a move return
if (SETTINGS.navBarTravelling === true) {
return;
}
// If we have content overflowing both sides or on the left
if (determineOverflow(pnProductNavContents, pnProductNav) === "left" || determineOverflow(pnProductNavContents, pnProductNav) === "both") {
// Find how far this panel has been scrolled
var availableScrollLeft = pnProductNav.scrollLeft;
// If the space available is less than two lots of our desired distance, just move the whole amount
// otherwise, move by the amount in the settings
if (availableScrollLeft < SETTINGS.navBarTravelDistance * 2) {
pnProductNavContents.style.transform = "translateX(" + availableScrollLeft + "px)";
} else {
pnProductNavContents.style.transform = "translateX(" + SETTINGS.navBarTravelDistance + "px)";
}
// We do want a transition (this is set in CSS) when moving so remove the class that would prevent that
pnProductNavContents.classList.remove("pn-ProductNav_Contents-no-transition");
// Update our settings
SETTINGS.navBarTravelDirection = "left";
SETTINGS.navBarTravelling = true;
}
// Now update the attribute in the DOM
pnProductNav.setAttribute("data-overflowing", determineOverflow(pnProductNavContents, pnProductNav));
});
pnAdvancerRight.addEventListener("click", function() {
// If in the middle of a move return
if (SETTINGS.navBarTravelling === true) {
return;
}
// If we have content overflowing both sides or on the right
if (determineOverflow(pnProductNavContents, pnProductNav) === "right" || determineOverflow(pnProductNavContents, pnProductNav) === "both") {
// Get the right edge of the container and content
var navBarRightEdge = pnProductNavContents.getBoundingClientRect().right;
var navBarScrollerRightEdge = pnProductNav.getBoundingClientRect().right;
// Now we know how much space we have available to scroll
var availableScrollRight = Math.floor(navBarRightEdge - navBarScrollerRightEdge);
// If the space available is less than two lots of our desired distance, just move the whole amount
// otherwise, move by the amount in the settings
if (availableScrollRight < SETTINGS.navBarTravelDistance * 2) {
pnProductNavContents.style.transform = "translateX(-" + availableScrollRight + "px)";
} else {
pnProductNavContents.style.transform = "translateX(-" + SETTINGS.navBarTravelDistance + "px)";
}
// We do want a transition (this is set in CSS) when moving so remove the class that would prevent that
pnProductNavContents.classList.remove("pn-ProductNav_Contents-no-transition");
// Update our settings
SETTINGS.navBarTravelDirection = "right";
SETTINGS.navBarTravelling = true;
}
// Now update the attribute in the DOM
pnProductNav.setAttribute("data-overflowing", determineOverflow(pnProductNavContents, pnProductNav));
});
pnProductNavContents.addEventListener(
"transitionend",
function() {
// get the value of the transform, apply that to the current scroll position (so get the scroll pos first) and then remove the transform
var styleOfTransform = window.getComputedStyle(pnProductNavContents, null);
var tr = styleOfTransform.getPropertyValue("-webkit-transform") || styleOfTransform.getPropertyValue("transform");
// If there is no transition we want to default to 0 and not null
var amount = Math.abs(parseInt(tr.split(",")[4]) || 0);
pnProductNavContents.style.transform = "none";
pnProductNavContents.classList.add("pn-ProductNav_Contents-no-transition");
// Now lets set the scroll position
if (SETTINGS.navBarTravelDirection === "left") {
pnProductNav.scrollLeft = pnProductNav.scrollLeft - amount;
} else {
pnProductNav.scrollLeft = pnProductNav.scrollLeft + amount;
}
SETTINGS.navBarTravelling = false;
},
false
);
You can view this stage here:
See the Pen wJgZYP by Ben Frain (@benfrain) on CodePen.
Note in the CSS:
.pn-ProductNav_Contents {
float: left;
transition: transform .2s ease-in-out;
}
.pn-ProductNav_Contents-no-transition {
transition: none;
}
In the JS the pn-ProductNav_Contents-no-transition
class is being added so that when the transform: none
is applied, we don’t see the panel scrolling back. This could also be handled with JS if preferred; this was just a personal preference.
Current indicator
It would be nice to have a indication of the currently active navigation item. Let’s handle that next.
We will add a listener to the scroller and add a ‘current’ class to the link that was clicked:
pnProductNavContents.addEventListener("click", function(e) {
// Make an array from each of the links in the nav
var links = [].slice.call(document.querySelectorAll(".pn-ProductNav_Link"));
// Turn all of them off
links.forEach(function(item) {
item.setAttribute("aria-selected", "false");
})
// Set the clicked one on
e.target.setAttribute("aria-selected", "true");
})
With that in place, you will now see basic styling (text going darker) for when each item is clicked. However, I’d like something a little fancier. My colleague, Tom Millard created something nice for the mobile site at my place of work (http://mobile.bet365.com). The main navigation line moves and resizes based upon the navigation item that is clicked; Let’s try and ape that functionality here.
A moving current indicator
Let’s use a ‘faceless’ span as our indicator. We will then move this around based upon which element is aria-selected="true"
as an extra visual queue. Here’s where it lives at the end of all the links:
<div class="pn-ProductNav_Wrapper">
<nav id="pnProductNav" class="pn-ProductNav">
<div id="pnProductNavContents" class="pn-ProductNav_Contents">
<!-- More Links -->
<a href="#" class="pn-ProductNav_Link">Worktops</a>
<span id="pnIndicator" class="pn-ProductNav_Indicator"></span>
</div>
</nav>
<!-- Buttons -->
</div>
By default it will be styled like this:
.pn-ProductNav_Indicator {
position: absolute;
bottom: 0;
left: 0;
height: 4px;
width: 100px;
background-color: #f90;
transform-origin: 0 0;
}
And so we have an over-long indicator in the DOM like this:
See the Pen BWWJPR by Ben Frain (@benfrain) on CodePen.
Now, we need to style and move it as nav items are clicked. Here’s the function that will do this for us:
function moveIndicator(item, color) {
var textPosition = item.getBoundingClientRect();
var container = pnProductNavContents.getBoundingClientRect().left;
console.log(textPosition, container);
var distance = textPosition.left - container;
var scrollPosition = pnIndicator.parentNode.scrollLeft;
pnIndicator.style.transform = "translateX(" + (distance + scrollPosition + pnProductNavContents.scrollLeft) + "px) scaleX(" + textPosition.width * 0.01 + ")";
if (color) {
pnIndicator.style.backgroundColor = color;
}
}
The function takes two parameters, item (the nav link being clicked) and color (we will come to that in time). It takes the item, finds it’s left edge and takes that value from the left edge of the container. This value is the distance we need the line to travel. We apply that value to the translateX property to move the line. Now the real clever bit from Tom; we can set the width of the indicator by scaling the default width of 100px by the width of the text element. Because the default size is 100 the sum is a more simple textPosition.width * 0.01
; I owe him a brownie for that one.
Note that the transform-origin
we set in the CSS becomes important now, otherwise, whilst the line would re-scale correctly, it would transform it’s scale from a center point instead of the top left.
Plus without the transition in CSS the line would just snap to the new position, the simple transition makes it zip about. A far more pleasing effect.
One other thing that happened. Because I was setting the border between the items with padding and margin, the indicator line was the wrong width; I want it the full width of the selection. Switching to padding on either side means an accurate width; kind of. Take a look at where we are now. Getting there but look at the pesky bits of white-space at the beginning of the line:
See the Pen peepXO by Ben Frain (@benfrain) on CodePen.
Fixing the white-space issue
I few minutes poking around and I remembered the cause. It’s good ol’ white-space that always appears for inline items. There are a number of ways around this, I’m opting to set the font-size to zero on the wrapper and reset it on the link items.
I’ve also amended the border slightly so things are sized more consistently. By having the border on all sides (despite only visible on one side), the measurements are more consistent. Here are changed property/values the links:
.pn-ProductNav_Link {
// Reset the font size
font-size: 1.2rem;
border: 1px solid transparent;
padding: 0 11px;
& + & {
border-left-color: #eee;
}
}
One final thing I want to do is set the indicator to the right width initially:
moveIndicator(pnProductNav.querySelector("[aria-selected=\"true\"]"));
Right, we are starting to look in pretty good shape now:
See the Pen dvvdLZ by Ben Frain (@benfrain) on CodePen.
However, there are a couple more features I would like to add. Firstly, a different indicator colour for each product. Secondly, the ability for a mouse user to drag the panel instead of clicking the advancer buttons. Let’s deal with the colour first.
Make the indicator change colour
Let’s make an object in JS with different colours for each key. Then we can use the position of the clicked item in the node list to pick a colour. Then we can apply that colour to the wrapping element and the indicator can inherit the colour. Here’s what the object with colours looks like:
var colours = {
0: "#867100",
1: "#7F4200",
2: "#99813D",
// More colours
}
And then we can pass the colour to the moveIndicator function like this:
moveIndicator(e.target, colours[links.indexOf(e.target)]);
For the second argument, we are looking at the colours object, and then choosing key which has a value equal to the clicking items position in the node list.
Now that our moveIndicator
function is receiving a color, it will change as we click. Let’s just add a transition to the colour change to smooth it further. This is what the CSS for indicator now looks like:
.pn-ProductNav_Indicator {
position: absolute;
bottom: 0;
left: 0;
height: 4px;
width: 100px;
background-color: transparent;
transform-origin: 0 0;
transition: transform .2s ease-in-out, background-color .2s ease-in-out;
}
And here is the result:
See the Pen MppBYa by Ben Frain (@benfrain) on CodePen.
Drag to scroll
The last feature I’d like to see is drag to scroll functionality. If a mouse user clicks on the nav and drags it, I want it to behave in the same way it would with touch and drag. I’m going to cheat at this point in a ‘here’s one I made earlier’ style and grab a great little JS script I found called ‘dragscroll’. You can find it on GitHub here: https://github.com/asvd/dragscroll/blob/master/dragscroll.js. Long story short, you include the JS, add a dragscroll
class to the scroll panel HTML and you’re done!
See the Pen zZZLaP by Ben Frain (@benfrain) on CodePen.
Conclusion
So now we have our scrolling panel with overflow indicators and clickable ‘advancer’ buttons. In addition we have employed a little JS script to allow drag to scroll functionality for mouse users.
I hope you learnt something reading this; probably not as much as I learnt writing it.
Nice work Ben, I will give it a try for my next dashboard design.