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:
Netflix correctly animates the carousel’s translateX
property, and it does so on all devices. This enables them to have a design that bleeds outside the carousel:
This, in turn, allows them to make a fancy design where items are enlarged outside of the x and y edges of the carousel and the surrounding items move out of their way:
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
touchmove
event 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!
Powered by WPeMatico