A CSS carousel with snapping points and a scroll-linked navigation

Chrome Canary 89 introduced @scroll-timeline, a «CSS property that allows animations to be driven by a container's scroll position».

There are a lot of excellent articles and demos already covering this topic and explaining how we could use this amazing feature: but trying to be creative I started to think about something that could have been improved, maybe something that usually works only with JavaScript and it is heavily scroll-related.

So after a few attempts I was able to code a carousel with a full working dot-navigation in pure CSS (if you want to skip the step-by-step explanation just scroll down at the end of the article for the links to the demos)

How does it look like?

Exactly like an old-JS-fashioned carousel.

Just remember: all the snippets work only on Chrome 94+.

Why a carousel?

Well, I'm aware that carousels are probably the most hated widget by every developer in this planet (if you're asking this question you are in that 136% of web developers who hate them) and they should be avoided but improving in some way this component without the need to load some extra JavaScript libraries – so as to make it a bit more accessible – it might make them a little less obnoxious (just a little, of course) and a little more interesting from a technical perspective.

Part 1: the slides

A simple list is enough to create the basic structure with all the slides, as in the codepen below: as you can see, you can interact with it by scrolling the slides left and right:

The markup is just a basic unordered list of images, but some explanation about the CSS can help.

  • block-size and inline-size are logical properties: they can interchargeably refer to height or width, depending on the writing mode.
    If you never ever heard about them don't worry: all you need to know is that - in this context – inline-size represents the width and block-size represents the height;

  • the <div> element is a container with a fixed aspect-ratio suitable for the images I've chosen, but there is nothing special with the current value (2:1) so feel free to adjust it to your need (or to remove it);

  • the inner flex list has flex-wrap set to nowrap and each list-item has a flex-basis: 100%. These properties will align the slides side-by-side and will make the list scrollable;

  • in order to hide that scrollbar the list is some pixel (25px) taller
    than the parent container (block-size: calc(100% + 25px))
    whose overflow is hidden. Now, for the sake of consistency, we just need to restore the height on the list items (block-size: calc(100% - 25px));

  • finally object-fit: cover adapts the size of the images to their parent list-items.

Part 2: snapping points

So far, after the first step, all we have is just a plain list of scrollable images. What we need to do now is to create a snapped scroll navigation, because we want the carousel to always show a full slide (and not something like 2 half slides at the same time).

From the previous codepen we inserted some new properties, in particular:

  • for the list we set scroll-snap-type: x mandatory;;
  • for the list-items we set scroll-snap-align: start; and scroll-snap-stop: always;.

These properties ensure that every swipe event on the carousel will end in a consistent state by always showing a full slide.

Part 3: dot-navigation

The next step is to add a dot navigation at the bottom, so we are able to jump from a slide to another one without to manually swipe all those in the middle.

The markup is going to be a navigation element (<nav>) with nested links pointing to each slide: try to click on the dots!

Since the dots are links to the inner anchors you can even share the page with a specific hash in the URL: on page load the body will scroll until it will reach the carousel with the selected slide.

We are almost done, we just need to first solve two main issues:

  1. How can we make it clear what's the current slide in the bottom navigation? Could we have the dot disabled when we click on it and coloured differently? At a first sight we can think to use the :active/:focus pseudoclasses but they won't be reliable.

  2. How can we update the bottom navigation when we navigate the carousel by gestures/mouse/trackpad? They are unrelated elements in the DOM and how a scroll event on the list can affect the state of the bottom navigation?

Part #4: Scroll-linked animation to the rescue!

This approach is so powerful that I'm going to cite one of my most preferred TV series:

"It is the program, it is the magic!"

Halt & Catch Fire, S01E01

These issues can be solved by setting a scroll-linked animation: in short, when an event changes the scroll position of the carousel, a custom animation on the ::before pseudoelement of the <nav> element will be triggered.

This pseudoelement is styled exactly like a navigation dot but with a higher opacity value and the animation will make it move over the dot corresponding to the current slide, working as a visual indicator and covering the underlying link.

The @keyframes and the style of the pseudoelement is the code that requires an in-depth focus:

@keyframes dot {
   0%    { --slide: 1; }
   12.5% { --slide: 2; }
   37.5% { --slide: 3; } 
   62.5% { --slide: 4; }
   87.5%, 100% { --slide: 5; }
}

These keyframes define the percentages of the scroll of the carousel that determine which slide we are currently looking at.

The first slide is fully visible when the scroll position is at 0% and the last slide is visible when this value is 100%: we want to move the pseudoelement from one dot to the next one when, on scrolling, we already see half of the next slide.

Since we have 5 slides in this demo this means that the 2nd slide will snap when the scroll position of the list is at 12.5% (in the middle, between 0 and 25%) while the 3rd slide will snap when this percentage is 37.5% (between 25% and 50%) and so on.

At those given percentages a CSS variable --slide will change with the index of the current slide.

Now, this is the scroll-timeline definition:

@scroll-timeline slide {
   source: selector(#s);
   orientation: inline; 
   time-range: 1s;
}

What I want to emphasize is the horizontal (inline) orientation and the source property, which is tied to the id (#s) of the list.

About the code of the pseudoelement we can also notice this rule:

transform: translateX(
   calc((100% + var(--gap, .5rem)) * 
   calc(var(--slide, 1) - 1))
);

The --gap variable has been defined for the nav element and it is the space between the dots, therefore both the variables are necessary inside this calc() expression in order to set the right translateX value to the overlapping pseudoelement.

Final words & thoughts

The final demo contains some extra goodies (like a smooth scroll-behavior, a scroll-padding-top to make some room above the carousel and the use of @supports to detect the browser capability).
I also made a more complex demo on codepen with another scroll-timeline on the body and a "reveal/unreveal" effect.

I hope you liked this proof of concept, it was really fun and challenging to code because I used a lot of advanced CSS.

The main benefits of this approach are to avoid at all the use of JavaScript, to ensure a better accessibility and to keep the control of this component (including the responsiveness part) entirely with CSS.

On the contrary, this technique could not be easily adopted for carousels in which the number of the slides is dynamic and driven by some backend logic (in that case you should provide some presets of all the possible keyframes and add some extra information in the markup).

Feel free to follow me on Codepen or Twitter where I usually talk about frontend and trees.

Note: since this is my first post on this community I'd like to celebrate it so I'll plant a tree for each retweet of the tweet below👇 in the next 12 days:

(up to 150 trees).

Update 1 — Jul. 19th, 2021

Happy to announce that my demo has been mentioned today on a CSS-Tricks article

18