Advertisement
  1. Web Design
  2. HTML/CSS
  3. Animation

Create the Perfect Carousel, Part 3

Scroll to top
This post is part of a series called Create the Perfect Carousel.
Create the Perfect Carousel, Part 2

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: 

Keyboard AccessibilityKeyboard AccessibilityKeyboard Accessibility

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:

1
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:

1
function onFocus(e) {
2
}

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

1
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.

1
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:

1
if (left < carouselLeft) {
2
  gotoPrev();
3
} else if (right > carouselLeft + sliderVisibleWidth) {
4
  gotoNext();
5
}

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:

1
const totalItemsWidth = getTotalItemsWidth(items);
2
const maxXOffset = 0;
3
4
let minXOffset = 0;
5
let sliderVisibleWidth = 0;
6
let clampXOffset;

Then, we can move the logic that previously calculated these values to a new measureCarousel function:

1
function measureCarousel() {
2
  sliderVisibleWidth = slider.offsetWidth;
3
  minXOffset = - (totalItemsWidth - sliderVisibleWidth);
4
  clampXOffset = clamp(minXOffset, maxXOffset);
5
}

We want to immediately invoke this function so we still set these values on initialisation. On the very next line, call measureCarousel:

1
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:

1
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:

1
const { applyOffset, clamp, nonlinearSpring, pipe } = transform;

In the determineDragDirection function, we have this code:

1
action.output(pipe(
2
  ({ x }) => x,
3
  applyOffset(action.x.get(), sliderX.get()),
4
  clampXOffset,
5
  (v) => sliderX.set(v)
6
));

Just above it, let's create our two springs, one for each scroll limit of the carousel:

1
const elasticity = 5;
2
const tugLeft = nonlinearSpring(elasticity, maxXOffset);
3
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:

1
const applySpring = (v) => {
2
  if (v > maxXOffset) return tugLeft(v);
3
  if (v < minXOffset) return tugRight(v);
4
  return v;
5
};

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:

1
const currentX = sliderX.get();
2
3
if (currentX < minXOffset || currentX > maxXOffset) {
4
  
5
} else {
6
  action = physics({
7
    from: currentX,
8
    velocity: sliderX.getVelocity(),
9
    friction: 0.2
10
  }).output(pipe(
11
    clampXOffset,
12
    (v) => sliderX.set(v)
13
  )).start();
14
}

Within the first clause of the if statement, we know that the slider is outside the scroll limits, so we can add our spring:

1
action = physics({
2
  from: currentX,
3
  to: (currentX < minXOffset) ? minXOffset : maxXOffset,
4
  spring: 800,
5
  friction: 0.92
6
}).output((v) => sliderX.set(v))
7
  .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:

1
transition: background 200ms linear;
2
3
&.disabled {
4
  background: #eee;
5
}

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:

1
<button class="prev disabled">Prev</button>

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:

1
function checkNavButtonStatus(x) {
2
  if (x <= minXOffset) {
3
    nextButton.classList.add('disabled');
4
  } else {
5
    nextButton.classList.remove('disabled');
6
7
    if (x >= maxXOffset) {
8
      prevButton.classList.add('disabled');
9
    } else {
10
      prevButton.classList.remove('disabled');
11
    }
12
  }
13
}

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:

1
(v) => sliderX.set(v)

With:

1
(v) => {
2
  sliderX.set(v);
3
  checkNavButtonStatus(v);
4
}

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:

1
const gotoNext = (e) => !e.target.classList.contains('disabled')
2
  ? goto(1)
3
  : notifyEnd(-1, maxXOffset);
4
5
const gotoPrev = (e) => !e.target.classList.contains('disabled')
6
  ? goto(-1)
7
  : notifyEnd(1, minXOffset);

The notifyEnd function is just another physics spring, and it looks like this:

1
function notifyEnd(delta, targetOffset) {
2
  if (action) action.stop();
3
  action = physics({
4
    from: sliderX.get(),
5
    to: targetOffset,
6
    velocity: 2000 * delta,
7
    spring: 300,
8
    friction: 0.9
9
  })
10
    .output((v) => sliderX.set(v))
11
    .start();
12
}

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:

1
progressBarRenderer.set('scaleX', progress);

With:

1
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:

1
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:

1
return () => {
2
  container.removeEventListener('touchstart', startTouchScroll);
3
  container.removeEventListener('wheel', onWheel);
4
  nextButton.removeEventListener('click', gotoNext);
5
  prevButton.removeEventListener('click', gotoPrev);
6
  slider.removeEventListener('focus', onFocus);
7
  window.removeEventListener('resize', measureCarousel);
8
};

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.

Advertisement
Did you find this post useful?
Want a weekly email summary?
Subscribe below and we’ll send you a weekly email summary of all new Web Design tutorials. Never miss out on learning about the next big thing.
Advertisement
Looking for something to help kick start your next project?
Envato Market has a range of items for sale to help get you started.