This is the third and final part of our Create the Perfect Carousel tutorial series. In part 1, we evaluated the carousels on Netflix and Amazon, two of the most heavily used carousels in the world. We set up our carousel and implemented touch scroll.
Then in part 2, we added horizontal mouse scroll, pagination, and a progress indicator. Boom.
Now, in our final part, we’re going to look into the murky and oft-forgotten world of keyboard accessibility. We’ll adjust our code to remeasure the carousel when the viewport size changes. And finally, we’ll a few finishing touches using spring physics.
You can pick up where we left off with this CodePen.
Keyboard Accessibility
It’s true that the majority of users do not rely on keyboard navigation, so sadly we sometimes forget about our users who do. In some countries, leaving a website inaccessible may be illegal. But worse, it’s a dick move.
The good news is that it’s usually easy to implement! In fact, browsers do the majority of the work for us. Seriously: try tabbing through the carousel we’ve made. Because we’ve used semantic markup, you already can!
Except, you’ll notice, our navigation buttons disappear. This is because the browser doesn’t allow focus on an element outside our viewport. So even though we have overflow: hidden
set, we are unable to scroll the page horizontally; otherwise, the page will indeed scroll to show the element with focus.
This is okay, and it would qualify, in my opinion, as “serviceable”, though not exactly delightful.
Netflix’s carousel also works in this manner. But because the majority of their titles are lazy-loaded, and they’re also passively keyboard-accessible (meaning they haven’t written any code specifically to handle it), we can’t actually select any titles beyond the few we’ve already loaded. It also looks terrible:
We can do better.
Handle the focus
Event
To do this, we’re going to listen to the focus
event that fires on any item in the carousel. When an item receives focus, we’re going to query it for its position. Then, we’ll check that against sliderX
and sliderVisibleWidth
to see if that item is within the visible window. If it isn’t, we’ll paginate to it using the same code we wrote in part 2.
At the end of the carousel
function, add this event listener:
slider.addEventListener('focus', onFocus, true);
You’ll notice we’ve provided a third parameter, true
. Rather than add an event listener to each item, we can use what’s known as event delegation to listen to events on just one element, their direct parent. The focus
event doesn’t bubble, so true
is telling the event listener to listen for the capture stage, the stage where the event fires on every element from the window
through to the target (in this case, the item receiving focus).
Above our growing block of event listeners, add the onFocus
function:
function onFocus(e) { }
We’ll be working in this function for the remainder of this section.
We need to measure the item’s left
and right
offset and check whether either point lies outside the currently viewable area.
The item is provided by the event’s target
parameter, and we can measure it with getBoundingClientRect
:
const { left, right } = e.target.getBoundingClientRect();
left
and right
are relative to the viewport, not the slider. So we need to get the carousel container’s left
offset to account for that. In our example, this will be 0
, but to make the carousel robust, it should account for being placed anywhere.
const carouselLeft = container.getBoundingClientRect().left;
Now, we can do a simple check to see if the item is outside the slider’s visible area and paginate in that direction:
if (left < carouselLeft) { gotoPrev(); } else if (right > carouselLeft + sliderVisibleWidth) { gotoNext(); }
Now, when we tab around, the carousel confidently paginates around with our keyboard focus! Just a few lines of code to show more love to our users.
Remeasure the Carousel
You might have noticed as you follow this tutorial that if you resize your browser viewport, the carousel doesn’t paginate properly any more. This is because we measured its width relative to its visible area just once, at the moment of initialisation.
To make sure our carousel behaves correctly, we need to replace some of our measurement code with a new event listener that fires when the window
resizes.
Now, near the start of your carousel
function, just after the line where we define progressBar
, we want to replace three of these const
measurements with let
, because we’re going to change them when the viewport changes:
const totalItemsWidth = getTotalItemsWidth(items); const maxXOffset = 0; let minXOffset = 0; let sliderVisibleWidth = 0; let clampXOffset;
Then, we can move the logic that previously calculated these values to a new measureCarousel
function:
function measureCarousel() { sliderVisibleWidth = slider.offsetWidth; minXOffset = - (totalItemsWidth - sliderVisibleWidth); clampXOffset = clamp(minXOffset, maxXOffset); }
We want to immediately invoke this function so we still set these values on initialisation. On the very next line, call measureCarousel
:
measureCarousel();
The carousel should work exactly as before. To update on window resize, we simply add this event listener at the very end of our carousel
function:
window.addEventListener('resize', measureCarousel);
Now, if you resize the carousel and try paginating, it’ll continue to work as expected.
A Note on Performance
It’s worth considering that in the real world, you might have multiple carousels on the same page, multiplying the performance impact of this measurement code by that amount.
As we briefly discussed in part 2, it is unwise to perform heavy calculations more often than you must. With pointer and scroll events, we said you want to perform those once per frame to help maintain 60fps. Resize events are a little different in that the entire document will reflow, probably the most resource-intensive moment a web page will encounter.
We don’t need to remeasure the carousel until the user has finished resizing the window, because they won’t interact with it in the meantime. We can wrap our measureCarousel
function in a special function called a debounce.
A debounce function essentially says: “Only fire this function when it hasn’t been called in over x
milliseconds.” You can read more about debounce on David Walsh’s excellent primer, and pick up some example code too.
Finishing Touches
So far, we’ve created a pretty good carousel. It’s accessible, it animates nicely, it works across touch and mouse, and it provides a great deal of design flexibility in a way natively scrolling carousels don’t allow.
But this isn’t the “Create a Pretty Good Carousel” tutorial series. It’s time for us to show off a little, and to do that, we’ve got a secret weapon. Springs.
We’re going to add two interactions using springs. One for touch, and one for pagination. They’re both going to let the user know, in a fun and playful way, that they’ve reached the end of the carousel.
Touch Spring
First, let’s add an iOS-style tug when a user’s trying to scroll the slider past its boundaries. Currently, we’re capping touch scroll using clampXOffset
. Instead, let’s replace this with some code that applies a tug when the calculated offset is outside its boundaries.
First, we need to import our spring. There’s a transformer called nonlinearSpring
which applies an exponentially increasing force against the number we provide it, towards an origin
. Which means that the further we pull the slider, the more it’ll tug back. We can import it like this:
const { applyOffset, clamp, nonlinearSpring, pipe } = transform;
In the determineDragDirection
function, we have this code:
action.output(pipe( ({ x }) => x, applyOffset(action.x.get(), sliderX.get()), clampXOffset, (v) => sliderX.set(v) ));
Just above it, let’s create our two springs, one for each scroll limit of the carousel:
const elasticity = 5; const tugLeft = nonlinearSpring(elasticity, maxXOffset); const tugRight = nonlinearSpring(elasticity, minXOffset);
Deciding on a value for elasticity
is a matter of playing around and seeing what feels right. Too low a number, and the spring feels too stiff. Too high and you won’t notice its tug, or worse, it’ll push the slider even further away from the user’s finger!
Now we just need to write a simple function that will apply one of these springs if the supplied value is outside the permitted range:
const applySpring = (v) => { if (v > maxXOffset) return tugLeft(v); if (v < minXOffset) return tugRight(v); return v; };
We can replace clampXOffset
in the code above with applySpring
. Now, if you pull the slider past its boundaries, it'll tug back!
However, when we let go of the spring, it sort of snaps unceremoniously back into place. We want to amend our stopTouchScroll
function, which currently handles momentum scrolling, to check if the slider is still outside the permitted range and, if so, apply a spring with the physics
action instead.
Spring Physics
The physics
action is capable of modelling springs, too. We just need to provide it with spring
and to
properties.
In stopTouchScroll
, move the existing scroll physics
initialisation to a piece of logic that makes sure we're within the scroll limits:
const currentX = sliderX.get(); if (currentX < minXOffset || currentX > maxXOffset) { } else { action = physics({ from: currentX, velocity: sliderX.getVelocity(), friction: 0.2 }).output(pipe( clampXOffset, (v) => sliderX.set(v) )).start(); }
Within the first clause of the if
statement, we know that the slider is outside the scroll limits, so we can add our spring:
action = physics({ from: currentX, to: (currentX < minXOffset) ? minXOffset : maxXOffset, spring: 800, friction: 0.92 }).output((v) => sliderX.set(v)) .start();
We want to create a spring that feels snappy and responsive. I've chosen a relatively high spring
value to have a tight "pop", and I've lowered the friction
to 0.92
to allow a little bounce. You could set this to 1
to eliminate the bounce entirely.
As a bit of homework, try replacing the clampXOffset
in the output
function of the scroll physics
with a function that triggers a similar spring when the x offset reaches its boundaries. Rather than the current abrupt stop, try making it bounce softly at the end.
Pagination Spring
Touch users always get the spring goodness, right? Let's share that love to desktop users by detecting when the carousel is at its scroll limits, and having an indicative tug to clearly and confidently show the user that they're at the end.
First, we want to disable the pagination buttons when the limit's been reached. Let's first add a CSS rule that styles the buttons to show that they're disabled
. In the button
rule, add:
transition: background 200ms linear; &.disabled { background: #eee; }
We're using a class here instead of the more semantic disabled
attribute because we still want to capture click events, which, as the name implies, disabled
would block.
Add this disabled
class to the Prev button, because every carousel starts life with a 0
offset:
Towards the top of carousel
, make a new function called checkNavButtonStatus
. We want this function to simply check the provided value against minXOffset
and maxXOffset
and set the button disabled
class accordingly:
function checkNavButtonStatus(x) { if (x <= minXOffset) { nextButton.classList.add('disabled'); } else { nextButton.classList.remove('disabled'); if (x >= maxXOffset) { prevButton.classList.add('disabled'); } else { prevButton.classList.remove('disabled'); } } }
It'd be tempting to call this every time sliderX
changes. If we did, the buttons would start flashing whenever a spring oscillated around the scroll boundaries. It'd also lead to weird behaviour if one of the buttons was pressed during one of those spring animations. The "scroll end" tug should always fire if we're at the end of the carousel, even if there's a spring animation pulling it away from the absolute end.
So we need to be more selective about when to call this function. It seems sensible to call it:
On the last line of the onWheel
, add checkNavButtonStatus(newX);
.
On the last line of goto
, add checkNavButtonStatus(targetX);
.
And finally, at the end of determineDragDirection
, and in the momentum scroll clause (the code within the else
) of stopTouchScroll
, replace:
(v) => sliderX.set(v)
With:
(v) => { sliderX.set(v); checkNavButtonStatus(v); }
Now all that's left is to amend gotoPrev
and gotoNext
to check their triggering button's classList for disabled
and only paginate if it's absent:
const gotoNext = (e) => !e.target.classList.contains('disabled') ? goto(1) : notifyEnd(-1, maxXOffset); const gotoPrev = (e) => !e.target.classList.contains('disabled') ? goto(-1) : notifyEnd(1, minXOffset);
The notifyEnd
function is just another physics
spring, and it looks like this:
function notifyEnd(delta, targetOffset) { if (action) action.stop(); action = physics({ from: sliderX.get(), to: targetOffset, velocity: 2000 * delta, spring: 300, friction: 0.9 }) .output((v) => sliderX.set(v)) .start(); }
Have a play with that, and again, tweak the physics
params to your liking.
There's just one small bug left. When the slider springs beyond its leftmost boundary, the progress bar is being inverted. We can quickly fix that by replacing:
progressBarRenderer.set('scaleX', progress);
With:
progressBarRenderer.set('scaleX', Math.max(progress, 0));
We could prevent it from bouncing the other way, but personally I think it's quite cool that it reflects the spring movement. It just looks odd when it flips inside out.
Clean Up After Yourself
With single-page applications, websites are lasting longer in a user's session. Often, even when the "page" changes, we're still running the same JS runtime as on the initial load. We can't rely on a clean slate every time the user clicks a link, and that means we have to clean up after ourselves to prevent event listeners firing on dead elements.
In React, this code is placed in the componentWillLeave
method. Vue uses beforeDestroy
. This is a pure JS implementation, but we can still provide a destroy method that would work equally in either framework.
So far, our carousel
function hasn't returned anything. Let's change that.
First, change the final line, the line that calls carousel
, to:
const destroyCarousel = carousel(document.querySelector('.container'));
We're going to return just one thing from carousel
, a function that unbinds all our event listeners. At the very end of the carousel
function, write:
return () => { container.removeEventListener('touchstart', startTouchScroll); container.removeEventListener('wheel', onWheel); nextButton.removeEventListener('click', gotoNext); prevButton.removeEventListener('click', gotoPrev); slider.removeEventListener('focus', onFocus); window.removeEventListener('resize', measureCarousel); };
Now, if you call destroyCarousel
and try to play with the carousel, nothing happens! It's almost a little sad to see it like this.
And That's That
Whew. That was a lot! How far we've come. You can see the finished product at this CodePen. In this final part, we've added keyboard accessibility, remeasuring the carousel when the viewport changes, some fun additions with spring physics, and the heartbreaking but necessary step of tearing it all down again.
I hope you enjoyed this tutorial as much as I enjoyed writing it. I'd love to hear your thoughts on further ways we could improve accessibility, or add more fun little touches.
Powered by WPeMatico