Back

Scrolling Tabs

April 2024

My attempt at tackling this common design pattern.

You don't have to look far on the internet to come across tabs or carousels.

Almost any website I work on these days includes at least one of them. I've seen dozens of implementations, and yet there always seems to be something missing.

So I decided to give it a go myself.

The goal here isn't to replace the numerous carousel and tab libraries out there.
Instead, I'd just like to explore what's possible using some basic CSS and JavaScript.

Key Idea

We can get scrollable, responsive, and animated Tabs with just 5 lines of CSS:

tabs.css
.container {
  display: flex; /* place slides in a row */
  overflow: auto; /* make container scrollable */
  position: relative; /* this will come in handy later */
}

.slide {
  width: 100%; /* make slides responsive */
  flex-shrink: 0; /* prevent shrinking */
}

This doesn't give us the tab triggers out of the box, but we do get a solid foundation to build upon.

We already have a natively scrollable container and responsive slides before touching javascript. (try scrolling to the right)

What we need

  1. A list of slides and triggers
  2. Clicking a trigger should scroll to the appropriate slide
tabs.html
<div class="triggers">
  <button onclick={scrollTab(slide_1)}>...
  <button onclick={scrollTab(slide_2)}>...
</div>

<div class="container">
  <div class="slide">...
  <div class="slide">...
</div>

How do we go about finding the correct scroll position?

Scroll Position

Rather than thinking of the content moving, we can reframe the idea of scrolling as moving a viewport across a fixed strip of content.

Turns out, the scroll position of a slide is just the amount of space to it's left.

You could probably spot the formula index * (width + gap).

This will work, but it means that we either need to use a fixed width and gap, or we need to calculate the widths and gaps.
(and make sure they stay up-to-date as the screen is resized)

A better way

Fortunately for us, there's a more direct way to get the scroll position without dealing with widths and gaps.

The offsetLeft property

This property returns the number of pixels that the upper left corner of the current element is offset to the left within it's parent.*

*This is only true if we have position:relative on the parent. Otherwise, offsetLeft returns the distance to the edge of the screen

Because display: flex places our items in a row, a slide's offsetLeft value is exactly equal to it's scroll position. (even with the overflow)

tabs.ts
const scrollTab = (slide) => {
  container.scrollTo({
    left: slide.offsetLeft,
    behavior: "smooth",
  });
};

That's all we need to implement the triggers.

Adding behavior: "smooth" nicely animates the scroll without us having to do anything else.

Also, because we calculate offsetLeft the moment a trigger is clicked, it will always be up-to-date.

Options & Enhancements

We can snap slides into place using the scroll-snap-type property.

tabs.css
.container {
  display: flex;
  overflow: auto; /* allow scrolling */
  scroll-snap-type: "x mandatory"; /* snap to slides */
}

.slide {
  width: 100%;
  flex-shrink: 0;
  scroll-snap-align: start; /* set snapping point */
}

We've actually been using this property all along, but I didn't mention it earlier because it's a little bit buggy out-of-the-box :(

scroll-snap works pretty well when scrolling, but there's some jank when using the triggers.

To get around this, we temporarily disable snapping when a trigger is clicked, and re-enable it after the finishes.


Next, we can set overflow: hidden to prevent scrolling and only allow navigation with the triggers.

overflow:autohidden

In general, it's a good idea to allow scrolling. This way, users can navigate using their preferred method.


Finally, you'll also notice the correct trigger highlights as we scroll. This is done using the Intersection Observer API.

Bloopers

I would've loved to implement a version with no javascript by using anchor tags for the triggers — <a href="#slide-id">... (and I did).

Unfortunately, it's extremely buggy:

  • Requires two clicks if the component isn't at the top of the page.
  • Slides don't line up correctly (notice the border being cut off).
  • Straight-up stops working if we try to add scroll-behavior: smooth.

Nonetheless, I've shared the implementation below if you'd like to play around with it.

scroll-behavior:autosmooth

Slide 1

Slide 2

Slide 3

Slide 4

Slide 5

If anyone knows why this is happening please let me know!. I'd love to revisit this idea in the future.

That's a wrap!

I hope you learned something from this Craft. I had a lot of fun putting it together :)

Digging into this UI pattern and experimenting with different approaches has given me a new appreciation for what goes into making a proper tabs or carousel library.

I'm fairly new to making these Craft pages, but I'm excited to share more of my experiments with you down the road.

If you have any feedback or suggestions, or just wanna chat, feel free to reach out!

Home
Portfolio
Crafts
Projects
Toggle Theme
Choose Color