Carousels are a staple of streaming and e-commerce sites. Both Amazon and Netflix use them as prominent navigation tools. In this tutorial, we'll evaluate the interaction design of both, and use our findings to implement the perfect carousel.
In this tutorial series, we'll also be learning some functions of Popmotion, a JavaScript motion engine. It offers animation tools like tweens (useful for pagination), pointer tracking (for scrolling), and spring physics (for our delightful finishing touches.)
Part 1 will evaluate how Amazon and Netflix have implemented scrolling. We'll then implement a carousel that can be scrolled via touch.
By the end of this series, we'll have implemented wheel and touchpad scroll, pagination, progress bars, keyboard navigation, and some little touches using spring physics. We'll also have been exposed to some basic functional composition.
Perfect?
What does it take for a carousel to be "perfect"? It has to be accessible by:
- Mouse: It should offer previous and next buttons that are easy to click and don't obscure content.
- Touch: It should track the finger, and then scroll with the same momentum as when the finger lifts from the screen.
- Scroll wheel: Often overlooked, the Apple Magic Mouse and many laptop trackpads offer smooth horizontal scrolling. We should utilise those capabilities!
- Keyboard: Many users prefer not to, or are unable to use a mouse for navigation. It's important we make our carousel accessible so those users can use our product too.
Finally, we'll take things that extra step further and make this a confident, delightful piece of UX by making the carousel respond clearly and viscerally with spring physics when the slider has reached the end.
Setup
First, let's get the HTML and CSS necessary to build a rudimentary carousel by forking this CodePen.
The Pen is set up with Sass for preprocessing CSS and Babel for transpiling ES6 JavaScript. I've also included Popmotion, which can be accessed with
window.popmotion.
You can copy the code to a local project if you prefer, but you'll need to ensure your environment supports Sass and ES6. You'll also need to install Popmotion with
npm install popmotion.
Creating a New Carousel
On any given page, we might have many carousels. So we need a method to encapsulate the state and functionality of each.
I'm going to use a factory function rather than a
class. Factory functions avoid the need to use the often-confusing
this keyword and will simplify the code for the purposes of this tutorial.
In your JavaScript editor, add this simple function:
function carousel(container) { } carousel(document.querySelector('.container'));
We'll be adding our carousel-specific code inside this
carousel function.
The Hows and Whys of Scrolling
Our first task is to make the carousel scroll. There are two ways we could go about this:
Native Browser Scrolling
The obvious solution would be to set
overflow-x: scroll on the slider. This would allow native scrolling on all browsers, including touch and horizontal mouse wheel devices.
There are, however, drawbacks to this approach:
- Content outside the container would not be visible, which can be restrictive for our design.
- It also limits the ways we can use animations to indicate we've reached the end.
- Desktop browsers will have an ugly (though accessible!) horizontal scroll bar.
Alternatively:
Animate
translateX
We could also animate the carousel's
translateX property. This would be very versatile as we'd be able to implement exactly the design we like.
translateX is also very performant, as unlike the CSS
left property it can be handled by the device's GPU.
On the downside, we'd have to reimplement scrolling functionality using JavaScript. That's more work, more code.
How Do Amazon and Netflix Approach Scrolling?
Both Amazon and Netflix carousels make different trade-offs in approaching this problem.
Amazon animates the carousel's
left property when in "desktop" mode. Animating
left is an incredibly poor choice, as changing it triggers a layout recalculation. This is CPU-intensive, and older machines will struggle to hit 60fps.
Whoever made the decision to animate
left instead of
translateX must be a real idiot (spoiler: it was me, back in 2012. We weren't as enlightened in those days.)
When it detects a touch device, the carousel uses the browser's native scrolling. The problem with only enabling this in "mobile" mode is desktop users with horizontal scroll wheels miss out. It also means any content outside the carousel will have to be visually cut off::
Unfortunately, Netflix's reimplementation of scrolling for touch devices is unsatisfactory: it uses a gesture-based pagination system which feels slow and cumbersome. There's also no consideration for horizontal scroll wheels.
We can do better. Let's code!
Scrolling Like a Pro
Our first move is to grab the
.slider node. While we're at it, let's grab the items it contains so we can figure out the slider's dimension.
function carousel(container) { const slider = container.querySelector('.slider'); const items = slider.querySelectorAll('.item'); }
Measuring the Carousel
We can figure out the visible area of the slider by measuring its width:
const sliderVisibleWidth = slider.offsetWidth;
We'll also want the total width of all the items contained within. To keep our
carousel function relatively clean, let's put this calculation in a separate function at the top of our file.
By using
getBoundingClientRect to measure the
left offset of our first item and the
right offset of our last item, we can use the difference between them to find the total width of all items.
function getTotalItemsWidth(items) { const { left } = items[0].getBoundingClientRect(); const { right } = items[items.length - 1].getBoundingClientRect(); return right - left; }
After our
sliderVisibleWidth measurement, write:
const totalItemsWidth = getTotalItemsWidth(items);
We can now figure out the maximum distance our carousel should be allowed to scroll. It's the total width of all our items, minus one full width of our visible slider. This provides a number that allows the rightmost item to align with the right of our slider:
const maxXOffset = 0; const minXOffset = - (totalItemsWidth - sliderVisibleWidth);
With these measurements in place, we're ready to start scrolling our carousel.
Setting
translateX
Popmotion comes with a CSS renderer for the simple and performant setting of CSS properties. It also comes with a value function which can be used to track numbers and, importantly (as we'll soon see), to query their velocity.
At the top of your JavaScript file, import them like so:
const { css, value } = window.popmotion;
Then, on the line after we set
minXOffset, create a CSS renderer for our slider:
const sliderRenderer = css(slider);
And create a
value to track our slider's x offset and update the slider's
translateX property when it changes:
const sliderX = value(0, (x) => sliderRenderer.set('x', x));
Now, moving the slider horizontally is as simple as writing:
sliderX.set(-100);
Try it!
Touch Scroll
We want our carousel to start scrolling when a user drags the slider horizontally and to stop scrolling when a user stops touching the screen. Our event handlers will look like this:
let action; function stopTouchScroll() { document.removeEventListener('touchend', stopTouchScroll); } function startTouchScroll(e) { document.addEventListener('touchend', stopTouchScroll); } slider.addEventListener('touchstart', startTouchScroll, { passive: false });
In our
startTouchScroll function, we want to:
- Stop any other actions powering
sliderX.
- Find the origin touch point.
- Listen to the next
touchmoveevent to see if the user is dragging vertically or horizontally.
After
document.addEventListener, add:
if (action) action.stop();
This will stop any other actions (like the physics-powered momentum scroll that we'll implement in
stopTouchScroll) from moving the slider. This will allow the user to immediately "catch" the slider if it scrolls past an item or title that they want to click on.
Next, we need to store the origin touch point. That will allow us to see where the user moves their finger next. If it's a vertical movement, we'll allow the scrolling of the page as usual. If it's a horizontal movement, we'll scroll the slider instead.
We want to share this
touchOrigin between event handlers. So after
let action; add:
let touchOrigin = {};
Back in our
startTouchScroll handler, add:
const touch = e.touches[0]; touchOrigin = { x: touch.pageX, y: touch.pageY };
We can now add a
touchmove event listener to the
document to determine the drag direction based on this
touchOrigin:
document.addEventListener('touchmove', determineDragDirection);
Our
determineDragDirection function is going to measure the next touch location, check it has actually moved and, if so, measure the angle to determine whether it's vertical or horizontal:
function determineDragDirection(e) { const touch = e.changedTouches[0]; const touchLocation = { x: touch.pageX, y: touch.pageY }; }
Popmotion includes some helpful calculators for measuring things like the distance between two x/y coordinates. We can import those like this:
const { calc, css, value } = window.popmotion;
Then measuring the distance between the two points is a matter of using the
distance calculator:
const distance = calc.distance(touchOrigin, touchLocation);
Now if the touch has moved, we can unset this event listener.
if (!distance) return; document.removeEventListener('touchmove', determineDragDirection);
Measure the angle between the two points with the
angle calculator:
const angle = calc.angle(touchOrigin, touchLocation);
We can use this to determine whether this angle is a horizontal or vertical angle, by passing it to the following function. Add this function to the very top of our file:
function angleIsVertical(angle) { const isUp = ( angle <= -90 + 45 && angle >= -90 - 45 ); const isDown = ( angle <= 90 + 45 && angle >= 90 - 45 ); return (isUp || isDown); }
This function returns
true if the provided angle is within -90 +/- 45 degrees (straight up) or 90 +/-45 degrees (straight down.) So we can add another
return clause if this function returns
true.
if (angleIsVertical(angle)) return;
Pointer Tracking
Now we know the user is trying to scroll the carousel, we can begin tracking their finger. Popmotion offers a pointer action that will output the x/y coordinates of a mouse or touch pointer.
First, import
pointer:
const { calc, css, pointer, value } = window.popmotion;
To track the touch input, provide the originating event to
pointer:
action = pointer(e).start();
We want to measure the initial
x position of our pointer and apply any movement to the slider. For that, we can use a transformer called
applyOffset.
Transformers are pure functions that take a value, and return it—yes—transformed. For instance:
const double = (v) => v * 2.
const { calc, css, pointer, transform, value } = window.popmotion; const { applyOffset } = transform;
applyOffset is a curried function. This means that when we call it, it creates a new function that can then be passed a value. We first call it with a number we want to measure the offset from, in this case the current value of
action.x, and a number to apply that offset to. In this case, that's our
sliderX.
So our
applyOffset function will look like this:
const applyPointerMovement = applyOffset(action.x.get(), sliderX.get());
We can now use this function in the pointer's
output callback to apply pointer movement to the slider.
action.output(({ x }) => slider.set(applyPointerMovement(x)));
Stopping, With Style
The carousel is now draggable by touch! You can test this by using device emulation in Chrome's Developer Tools.
It feels a little janky, right? You may have encountered scrolling that feels like this before: You lift your finger, and the scrolling stops dead. Or the scrolling stops dead and then a little animation takes over to fake a continuation of the scrolling.
We're not going to do that. We can use the physics action in Popmotion to take the true velocity of
sliderX and apply friction to it over a duration of time.
First, add it to our ever-growing list of imports:
const { calc, css, physics, pointer, value } = window.popmotion;
Then, at the end of our
stopTouchScroll function, add:
if (action) action.stop(); action = physics({ from: sliderX.get(), velocity: sliderX.getVelocity(), friction: 0.2 }) .output((v) => sliderX.set(v)) .start();
Here,
from and
velocity are being set with the current value and velocity of
sliderX. This ensures our physics simulation has the same initial starting conditions as the user's dragging motion.
friction is being set as
0.2. Friction is set as a value from
0 to
1, with
0 being no friction at all and
1 being absolute friction. Try playing around with this value to see the change it makes to the "feeling" of the carousel when a user stops dragging.
Smaller numbers will make it feel lighter, and larger numbers will make movement heavier. For a scrolling motion, I feel
0.2 hits a nice balance between erratic and sluggish.
Boundaries
But there's a problem! If you've been playing around with your new touch carousel, it's obvious. We haven't bounded movement, making it possible to literally throw your carousel away!
There's another transformer for this job,
clamp. This is also a curried function, meaning if we call it with a min and max value, say
0 and
1, it will return a new function. In this example, the new function will restrict any number given to it to between
0 and
1:
clamp(0, 1)(5); // returns 1
First, import
clamp:
const { applyOffset, clamp } = transform;
We want to use this clamping function across our carousel, so add this line after we define
minXOffset:
const clampXOffset = clamp(minXOffset, maxXOffset);
We're going to amend the two
output we've set on our actions using some light functional composition with the
pipe transformer.
Pipe
When we call a function, we write it like this:
foo(0);
If we want to give the output of that function to another function, we might write that like this:
bar(foo(0));
This becomes slightly difficult to read, and it only gets worse as we add more and more functions.
With
pipe, we can compose a new function out of
foo and
bar which we can reuse:
const foobar = pipe(foo, bar); foobar(0);
It's also written in a natural start -> finish order, which makes it easier to follow. We can use this to compose
applyOffset and
clamp into a single function. Import
pipe:
const { applyOffset, clamp, pipe } = transform;
Replace the
output callback of our
pointer with:
pipe( ({ x }) => x, applyOffset(action.x.get(), sliderX.get()), clampXOffset, (v) => sliderX.set(v) )
And replace the
output callback of
physics with:
pipe(clampXOffset, (v) => sliderX.set(v))
This kind of functional composition can quite neatly create descriptive, step-by-step processes out of smaller, reusable functions.
Now, when you drag and throw the carousel, it won't budge outside of its boundaries.
The abrupt stop isn't very satisfying. But that's a problem for a later part!
Conclusion
That's all for part 1. So far, we've taken a look at existing carousels to see the strengths and weaknesses of different approaches to scrolling. We've used Popmotion's input tracking and physics to performantly animate our carousel's
translateX with touch scrolling. We've also been introduced to functional composition and curried functions.
You can grab a commented version of the "story so far" on this CodePen.
In upcoming installments, we'll look at:
- scrolling with a mouse wheel
- remeasuring the carousel when the window resizes
- pagination, with keyboard and mouse accessibility
- delightful touches, with the help of spring physics
Look forward to seeing you there!
Envato Tuts+ tutorials are translated into other languages by our community members—you can be involved too!Translate this post
