Welcome back to the Create the Perfect Carousel tutorial series. We’re making an accessible and delightful carousel using JavaScript and Popmotion’s physics, tween and input tracking capabilities.
In part 1 of our tutorial, we took a look at how Amazon and Netflix have created their carousels and evaluated the pros and cons of their approaches. With our learnings, we decided on a strategy for our carousel and implemented touch scrolling using physics.
In part 2, we’re going to implement horizontal mouse scroll. We’re also going to look at some common pagination techniques and implement one. Finally, we’re going to hook up a progress bar that will indicate how far through the carousel the user is.
You can restore your save point by opening this CodePen, which picks up where we left off.
Horizontal Mouse Scroll
It’s rare that a JavaScript carousel respects horizontal mouse scroll. This is a shame: on laptops and mice that implement momentum-based horizontal scrolling, this is by far the quickest way to navigate the carousel. It’s as bad as forcing touch users to navigate via buttons rather than swipe.
Luckily, it can be implemented in just a few lines of code. At the end of your carousel
function, add a new event listener:
container.addEventListener('wheel', onWheel);
Below your startTouchScroll
event, add a stub function called onWheel
:
function onWheel(e) { console.log(e.deltaX) }
Now, if you run the scroll wheel over the carousel and check your console panel, you’ll see the wheel distance on the x-axis output.
As with touch, if wheel movement is mostly vertical, the page should scroll as usual. If it’s horizontal, we want to capture the wheel movement and apply it to the carousel. So, in onWheel
, replace the console.log
with:
const angle = calc.angle({ x: e.deltaX, y: e.deltaY }); if (angleIsVertical(angle)) return; e.stopPropagation(); e.preventDefault();
This block of code will stop page scroll if the scroll is horizontal. Updating our slider’s x offset is now just a matter of taking the event’s deltaX
property and adding that to our current sliderX
value:
const newX = clampXOffset( sliderX.get() + - e.deltaX ); sliderX.set(newX);
We’re reusing our previous clampXOffset
function to wrap this calculation and make sure the carousel doesn’t scroll beyond its measured boundaries.
An Aside on Throttling Scroll Events
Any good tutorial that deals with input events will explain how important it is to throttle those events. This is because scroll, mouse and touch events can all fire faster than the device’s frame rate.
You don’t want to perform unnecessary resource-intensive work like rendering the carousel twice in one frame, as it’s a waste of resources and a quick way to make a sluggish-feeling interface.
This tutorial hasn’t touched on that because the renderers provided by Popmotion implement Framesync, a tiny frame-synced job scheduler. This means you could call (v) => sliderRenderer.set('x', v)
multiple times in a row, and the expensive rendering would only happen once, on the next frame.
Pagination
That’s scrolling finished. Now we need to inject some life into the hitherto unloved navigation buttons.
Now, this tutorial is about interaction, so feel free to design these buttons as you wish. Personally, I find direction arrows more intuitive (and fully internationalised by default!).
How Should Pagination Work?
There are two clear strategies we could take when paginating the carousel: item-by-item or first obscured item. There’s only one correct strategy but, because I’ve seen the other one implemented so often, I thought it’d be worth explaining why it’s incorrect.
1. Item by Item
Simply measure the x offset of the next item in the list and animate the shelf by that amount. It’s a very simple algorithm that I assume is picked for its simplicity rather than its user-friendliness.
The problem is that most screens will be able to show lots of items at a time, and people will scan them all before trying to navigate.
It feels sluggish, if not outright frustrating. The only situation in which this would be a good choice is if you know the items in your carousel are the same width or only slightly smaller than the viewable area.
However, if we’re looking at multiple items, we’re better using the first obscured item method:
2. First Obscured Item
This method simply looks for the first obscured item in the direction we want to move the carousel, takes its x offset, and then scrolls to that.
In doing so, we pull in the maximum number of new items working on the assumption that the user has seen all those currently present.
Because we’re pulling in more items, the carousel requires fewer clicks to navigate around. Faster navigation will increase engagement and ensure your users see more of your products.
Event Listeners
First, let’s set up our event listeners so we can start playing around with the pagination.
We first need to select our previous and next buttons. At the top of the carousel
function, add:
const nextButton = container.querySelector('.next'); const prevButton = container.querySelector('.prev');
Then, at the bottom of the carousel
function, add the event listeners:
nextButton.addEventListener('click', gotoNext); prevButton.addEventListener('click', gotoPrev);
Finally, just above your block of event listeners, add the actual functions:
function goto(delta) { } const gotoNext = () => goto(1); const gotoPrev = () => goto(-1);
goto
is the function that’s going to handle all the logic for pagination. It simply takes a number which represents the direction of travel we wish to paginate. gotoNext
and gotoPrev
simply call this function with 1
or -1
, respectively.
Calculating a “Page”
A user can freely scroll this carousel, and there are n
items within it, and the carousel might be resized. So the concept of a traditional page is not directly applicable here. We won’t be counting the number of pages.
Instead, when the goto
function is called, we’re going to look in the direction of delta
and find the first partially obscured item. That will become the first item on our next “page”.
The first step is to get the current x offset of our slider, and use that with the full visible width of the slider to calculate an “ideal” offset to which we’d like to scroll. The ideal offset is what we would scroll to if we were naive to the contents of the slider. It provides a nice spot for us to start searching for our first item.
const currentX = sliderX.get(); let targetX = currentX + (- sliderVisibleWidth * delta);
We can use a cheeky optimisation here. By providing our targetX
to the clampXOffset
function we made in the previous tutorial, we can see if its output is different to targetX
. If it is, it means our targetX
is outside of our scrollable bounds, so we don’t need to figure out the closest item. We just scroll to the end.
const clampedX = clampXOffset(targetX); targetX = (targetX === clampedX) ? findClosestItemOffset(targetX, delta) : clampedX;
Finding the Closest Item
It’s important to note that the following code works on the assumption that all of the items in your carousel are the same size. Under that assumption, we can make optimisations like not having to measure the size of every item. If your items are different sizes, this will still make a good starting point.
Above your goto
function, add the findClosestItemOffset
function referenced in the last snippet:
function findClosestItem(targetX, delta) { }
First, we need to know how wide our items are and the spacing between them. The Element.getBoundingClientRect()
method can provide all the information we need. For width
, we simply measure the first item element. To calculate the spacing between items, we can measure the right
offset of the first item and the left
offset of the second, and then subtract the former from the latter:
const { right, width } = items[0].getBoundingClientRect(); const spacing = items[1].getBoundingClientRect().left - right;
Now, with the targetX
and delta
variables we passed through to the function, we have all the data we need to quickly calculate an offset to scroll to.
The calculation is to divide the absolute targetX
value by the width + spacing
. This will give us the exact number of items we can fit inside that distance.
const totalItems = Math.abs(targetX) / (width + spacing);
Then, round up or down depending on the direction of pagination (our delta
). This will give us the number of complete items we can fit.
const totalCompleteItems = delta === 1 ? Math.floor(totalItems) : Math.ceil(totalItems);
Finally, multiply that number by width + spacing
to give us an offset flush with a full item.
return 0 - totalCompleteItems * (width + spacing);
Animate the Pagination
Now that we’ve got our targetX
calculated, we can animate to it! For this, we’re going to use the workhorse of web animation, the tween.
For the uninitiated, “tween” is short for between. A tween changes from one value to another, over a set duration of time. If you’ve used CSS transitions, this is the same thing.
There are a number of benefits (and shortcomings!) to using JavaScript over CSS for tweens. In this instance, because we’re also animating sliderX
with physics and user input, it will be easier for us to stay in this workflow for the tween.
It also means that later on we can hook up a progress bar and it’ll work naturally with all our animations, for free.
We first want to import tween
from Popmotion:
const { calc, css, easing, physics, pointer, transform, tween, value } = window.popmotion;
At the end of our goto
function, we can add our tween that animates from currentX
to targetX
:
tween({ from: currentX, to: targetX, onUpdate: sliderX }).start();
By default, Popmotion sets duration
to 300
milliseconds and ease
to easing.easeOut
. These have been picked specifically to provide a responsive feel to animations that respond to user input, but feel free to play around and see if you come up with something that better fits the feel of your brand.
Progress Indicator
It’s useful for users to have some indication about where in the carousel they are. For this, we can hook up a progress indicator.
Your progress bar could be styled in a number of ways. For this tutorial, we’ve made a coloured div, 5px high, that runs between the previous and next buttons. It’s the way that we hook this up to our code and animate the bar that is important and is the focus of this tutorial.
You haven’t seen the indicator yet because we originally styled it with transform: scaleX(0)
. We use a scale
transform to adjust the width of the bar because, as we explained in part 1, transforms are more performant than changing properties like left
or, in this case, width
.
It also allows us to easily write code that sets the scale as a percentage: the current value of sliderX
between minXOffset
and maxXOffset
.
Let’s start by selecting our div.progress-bar
after our previousButton
selector:
const progressBar = container.querySelector('.progress-bar');
After we define sliderRenderer
, we can add a renderer for progressBar
:
const progressBarRenderer = css(progressBar);
Now let’s define a function to update the scaleX
of the progress bar.
We’ll use a calc
function called getProgressFromValue
. This takes a range, in our case min
and maxXOffset
, and a third number. It returns the progress, a number between 0
and 1
, of that third number within the given range.
function updateProgressBar(x) { const progress = calc.getProgressFromValue(maxXOffset, minXOffset, x); progressBarRenderer.set('scaleX', progress); }
We’ve written the range here as maxXOffset, minXOffset
when, intuitively, it should be reversed. This is because x
is a negative value, and maxXOffset
is also a negative value whereas minXOffset
is 0
. The 0
is technically the larger of the two numbers, but the smaller value actually represents the maximum offset. Negatives, huh?
We want the progress indicator to update in lockstep with sliderX
, so let’s change this line:
const sliderX = value(0, (x) => sliderRenderer.set('x', x));
To this line:
const sliderX = value(0, (x) => { updateProgressBar(x); sliderRenderer.set('x', x); });
Now, whenever sliderX
updates, so will the progress bar.
Conclusion
That’s it for this instalment! You can grab the latest code on this CodePen. We’ve successfully introduced horizontal wheel scrolling, pagination, and a progress bar.
The carousel is in pretty good shape so far! In the final instalment, we’re going to take it a step further. We’ll make the carousel fully keyboard accessible to ensure anyone can use it.
We’re also going to add a couple of delightful touches using a spring-powered tug when a user tries to scroll the carousel past its boundaries using either touch scroll or pagination.
See you then!
Powered by WPeMatico