Back to gallery

Design engineering: a carousel component

A headless, zero-dependency, scrollable, and swipeable carousel, even on desktop (complete with snapping, friction, rubber-banding and overscroll). Inspired by a component made at Finary, a one-stop shop for wealth management. Play with the component, and try changing the card size. See install instructions.

More demos

The good thing is, this carousel is fully native, headless, and scroll-driven, so you can get creative with scroll driven animations too, provided your browser supports them (all major browsers support them, except Firefox), here are some examples from the awesome blossom carousel library, which is very similar to this one:

Install

Open the repo in Github (and drop a star if you like it!)

npm install @daformat/react-headless-carousel

Things to try

Momentum scrolling

Of course you can scroll the regular way, but you can also drag to swipe the carousel. On most browsers (cough cough, Safari...) dragging to swipe will respect the css scroll-snap-align. When you drag to swipe, we use a custom momentum scrolling implementation when needed (desktop browsers). The greater the velocity, the further the carousel will scroll.

Overscroll / rubber-banding

When dragging to swipe, if you give it enough velocity the carousel will overscroll, with a rubber-banding effect, similar to the one you get on touch devices by default. For this to work, we calculate a velocity based on how fast you are moving your mouse and apply a deceleration factor. When yous scrolled to the start of the end of the carousel, you can also trigger the rubber-banding effect by dragging the carousel even more.

Pagination

The carousel can be paginated, using the dedicated buttons, pagination is based on the scroll-snap-align set in css. Pagination accounts for the fade mask if any, or whatever offset is returned by the boundaryOffset, this allows to ensure the next item is always fully visible, instead of being partially masked, ensuring better pagination.

How far a click goes is up to you, with a mode on the buttons. The default, page, takes the next item the viewport was cutting off and brings it fully into view, so nothing stays half seen. item steps to the next item along instead, whether or not the current one was fully visible, which suits a carousel showing one card at a time. And viewport moves by exactly what the viewport can show and lets the items fall where they may. Autoplay takes the same three names, so a carousel whose autoplay steps by item and whose buttons page through it says so in the same words.

Snapping

You can play withe the demo controls to change the snapping. Thanks to the css styles, the carousel items will snap naturally when performing a regular scroll. But when you drag to scroll on desktop, this behavior is not a given. You have to implement it yourself by adjusting the deceleration factor for the velocity, so that the velocity reaches 0 towards the snap point. Snapping is also respected when using pagination or whn tabbing.

Looping

Tick the Loop checkbox and the carousel becomes endless in both directions. Under the hood it renders your items three times over, plus however many extra copies it takes for one set to be wider than a few viewports, then teleports the scroll position back by a whole number of copies whenever it gets close to running out. The position it leaves and the one it lands on show the exact same pixels, so you never see the jump. It starts on your first real item, and every copy gets aria-hidden and tabindex="-1", so screen readers and the tab key skip the copies themselves.

That covers each copy, but not what’s inside it. Neither attribute reaches a descendant, so a copy holding a button still has that button in the tab order. That’s usually what you want: a copy you can see is a real part of the carousel as far as you’re concerned, and it stays clickable too. It does mean tabbing walks through the copies in DOM order, which isn’t the order they come round on screen, so there are three bits of help for that:

  • Tabbing in goes to what you can see. The first thing in the tab order lives in the very first copy, right back at the start of the content, and going to fetch it would sweep the carousel all the way there. So the focus is handed to the first fully visible item instead, and nothing moves. Shift-tabbing in takes the last visible one.
  • Tabbing on never doubles back. When the next element in the tab order sits a long way behind you, the carousel jumps whole copies towards it first. Both sides of that jump show the same pixels, so you never see the distance, and the small remainder is animated in the direction you were already tabbing. If the scroll can’t go that way because there’s no more content on that side, the focus goes to the copy already within reach and the carousel stays put.
  • The focus ring follows the jump. A teleport carries whatever was focused inside a copy off screen with it, so the focus moves to the copy that took its place. Without that, the outline looks like it vanishes a moment after you tab.

All three are keyboard only. Clicking still focuses whatever you clicked, copy or not.

Looping and snapping are a best-effort pairing though. Every browser drives a wheel scroll towards a snap point it picks when the gesture starts, and none of them take kindly to the scroll position moving underneath: Chromium scrolls back to the item it had chosen, Safari swallows the rest of the momentum without applying it, and Firefox drops the snap it was about to make. So when a wrap disturbs a scroll, the carousel takes snapping off the browser for the rest of that gesture and applies it itself once everything stops, animating to the position your scroll-snap-type asks for. Chromium commits to its target early enough that the whole gesture has to run that way. Dragging is unaffected, since it has always managed its own snapping. So the carousel still lands on a snap point every time it comes to rest, but exactly how it gets there varies by engine, and a very long fling can still show a seam. If you need snapping to be exact under all circumstances, leave looping off.

Autoplay

Tick Autoplay and the carousel scrolls on its own. The dropdown next to it switches between the modes below. Passing autoplay on its own steps to the next item every three seconds, or you can pass an object to choose how it moves: mode is any of the three the arrows take, item, page or viewport, plus continuous, which scrolls at a steady speed without stopping on items. Stepping takes an interval in milliseconds, a continuous scroll takes a speed in pixels per second, and both can go backwards. A carousel that does not loop also gets to say what happens when it runs out of content with atEnd: go back to the beginning, turn around and play back the way it came, or simply stop, with an optional pause at each end before it does.

It also knows when to get out of the way. It pauses while your pointer is over the carousel and while focus is inside it, and it stands aside while a wheel gesture’s momentum is still running, or while the tab is in the background. Hovering and focusing both assume a mouse or a keyboard though, and touch is neither, so scrolling and dragging pause it too. It picks up again a second and a half after the carousel has come to rest, which is later than when you let go: a flick hands over to momentum, and the browser may still have snapping to do. That delay is pauseOnInteraction in milliseconds, and false keeps it playing throughout.

Reduced motion

A carousel sweeps a good part of the screen sideways, which is the kind of movement prefers-reduced-motion: reduce exists for, so it is respected by default in the two places the carousel moves of its own accord. Autoplay doesn’t run at all, rather than merely pausing, since a paused autoplay is still something that starts moving the moment your pointer leaves. And the scrolls the carousel animates arrive instantly instead: the prev and next buttons, tabbing to an item that is off screen, an autoplay rewinding, and the snapping it finishes on the browser’s behalf after a loop wrap. The destination is what you asked for, the journey is the decoration.

Dragging is untouched, and so is the momentum that carries on from it. That motion is your own hand, and stopping the carousel dead under your finger would be less control rather than less motion. Same for the rubber-banding at the ends, and for looping, whose copies are structure rather than movement. If your app has already made this decision somewhere else, reducedMotion="ignore" opts out of all of it.

Tabbing through the carousel items

Full support for tabbing through the carousel items, provided the items contain tabbable content. Here again, when tabbing through, the carousel fully enforces the desired scroll-snap-align and makes sure the tabbed item is fully visible instead of being partially hidden by the mask, or if you choose to render the prev / next buttons on top of the carousel, you can provide a custom boundaryOffset function to account for these. Tabbing through a carousel that loops has a bit more going on, see looping above.

Scroll fade

Most scroll fades only animate the opacity when you reach the edges of the scroll area. Instead of doing this, I decided to animate the length of the mask based on the remaining scroll distance. This way, the fading effect is more natural, and the transition is smoother. As you approach the edges, the mask smoothly updates.

Component structure

{/* Provides context to the carousel components */}
<Carousel.Root>
  {/* The scrollable area */}
  <Carousel.Viewport>
    {/* The container for the items */}
    <Carousel.Content>
      {/* A carousel item */}
      <Carousel.Item />
      <Carousel.Item />
      <Carousel.Item />
    </Carousel.Content>
  </Carousel.Viewport>
  {/* The pagination buttons */}
  <Carousel.PrevPage />
  <Carousel.NextPage />
</Carousel.Root>

That’s a wrap

While implementing the basic version of the carousel is easy, thanks to modern css, implementing momentum scrolling with snapping and overscroll / rubber-banding on desktop isn’t trivial. Infinite scrolling is in there now too, along with autoplay. Getting a loop and native snapping to agree with each other turned out to be a story of its own, one browser at a time.

Up next

Rolling stacking cards
-->
<--

Right before

A macOS inspired dock