How to Make a “Scroll to Select” Form Control

The <select> element is a fairly straightforward concept: focus on it to reveal a set of <option>s that can be selected as the input’s value. That’s a great pattern and I’m not suggesting we change it. That said, I do enjoy poking at things and found an interesting way to turn a <select> into a dial of sorts — where options are selected by scrolling them into position, not totally unlike a combination lock or iOS date pickers. Anyone who’s expanded a <select> for selecting a country knows how painfully long lists can be and this could be one way to prevent that.

Here’s what I’m talking about:

It’s fairly common knowledge that styling <select> in CSS is not the easiest thing in the world. But here’s the trick: we’re not working with <select> at all. No, we’re not going to do anything like building our own <select> by jamming a bunch of JavaScript into a <div>. We’re still working with semantic form controls, only it’s radio buttons.

<section class=scroll-container>
  <label for="madrid" class="scroll-item">
      Madrid 
      <abbr>MAD</abbr>
      <input id="madrid" type="radio" name="items">
  </label>
  <label for="malta" class="scroll-item">
      Malta 
      <abbr>MLA</abbr>
      <input id="malta" type="radio" name="items">
  </label>
  <!-- etc. -->
</section>

What we need is to style the list of selectable controls where we are capable of managing their sizes and spacing in CSS. I’ve gone with a group of labels with nested radio boxes as far as the markup goes. The exact styling is totally up to you, of course, but you can use these base styles I wrote up if you want a starting point.

.scroll-container {
  /* SIZING & LAYOUT */
  --itemHeight: 60px;
  --itemGap: 10px;
  --containerHeight: calc((var(--itemHeight) * 7) + (var(--itemGap) * 6));

  width: 400px; 
  height: var(--containerHeight);
  align-items: center;
  row-gap: var(--itemGap);
  border-radius: 4px;

  /* PAINT */
  --topBit: calc((var(--containerHeight) - var(--itemHeight))/2);
  --footBit: calc((var(--containerHeight) + var(--itemHeight))/2);

  background: linear-gradient(
    rgb(254 251 240), 
    rgb(254 251 240) var(--topBit), 
    rgb(229 50 34 / .5) var(--topBit), 
    rgb(229 50 34 / .5) var(--footBit), 
    rgb(254 251 240) 
    var(--footBit));
  box-shadow: 0 0 10px #eee;
}

A couple of details on this:

  • --itemHeight is the height of each item in the list.
  • --itemGap is meant to be the space between two items.
  • The --containerHeight variable is the .scroll-container’s height. It’s the sum of the item sizes and the gaps between them, ensuring that we display, at maximum, seven items at once. (An odd number of items gives us a nice balance where the selected item is directly in the vertical center of the list). 
  • The background is a striped gradient that highlights the middle area, i.e., the location of the currently selected item. 
  •  The --topBit and –-footBit variables are color stops that visually paint in the middle area (which is orange in the demo) to represent the currently selected item.

I’ll arrange the controls in a vertical column with flexbox declared on the .scroll-container:

.scroll-container {
  display: flex; 
  flex-direction: column;
  /* rest of styles */
}

With layout work done, we can focus on the scrolling part of this. If you haven’t worked with CSS Scroll Snapping before, it’s a convenient way to direct a container’s scrolling behavior. For example, we can tell the .scroll-container that we want to enable scrolling in the vertical direction. That way, it’s possible to scroll to the rest of the items that are not in view.

.scroll-container {
  overflow-y: scroll;
  /* rest of styles */
}

Next, we reach for the scroll-snap-style property that can be used to tell the .scroll-container that we want scrolling to stop on an item — not near an item, but directly on it.

.scroll-container {
  overflow-y: scroll;
  scroll-snap-type: y mandatory;
  /* rest of styles */
}

Now items “snap” onto an item instead of allowing a scroll to end wherever it wants. One more little detail I like to include is overscroll-behavior, specifically along the y-axis as far as this demo goes:

.scroll-container {
  overflow-y: scroll;
  scroll-snap-type: y mandatory;
  overscroll-behavior-y: none;
  /* rest of styles */
}

overscroll-behavior-y: none isn’t required to make this work, but when someone scrolls through the .scroll-container (along the y-axis), scrolling stops once the boundary is reached, and any further continued scrolling action will not trigger scrolling in any nearby scroll containers. Just a form of defensive CSS.

Time to move to the items inside the scroll container. But before we go there, here are some base styles for the items themselves that you can use as a starting point:

.scroll-item {
  /* SIZING & LAYOUT */
  width: 90%;
  box-sizing: border-box;
  padding-inline: 20px;
  border-radius: inherit; 

  /* PAINT & FONT */
  background: linear-gradient(to right, rgb(242 194 66), rgb(235 122 51));
  box-shadow: 0 0 4px rgb(235 122 51);
  font: 16pt/var(--itemHeight) system-ui;
  color: #fff;

  input { appearance: none; } 
  abbr { float: right; } /* The airport code */
}

As I mentioned earlier, the --itemHeight variable is setting as the size of each item and we’re declaring it on the flex property — flex: 0 0 var(--itemHeight). Margin is added before and after the first and last items, respectively, so that every item can reach the middle of the container through scrolling. 

The scroll-snap-align property is there to give the .scroll-container a snap point for the items. A center alignment, for instance, snaps an item’s center (vertical center, in this case) with the .scroll-container‘s center (vertical center as well). Since the items are meant to be selected through scrolling alone pointer-events: none is added to prevent selection from clicks.

One last little styling detail is to set a new background on an item when it is in a :checked state:

.scroll-item {
  /* Same styles as before */

  /* If input="radio" is :checked */
  &:has(:checked) {
    background: rgb(229 50 34);
  }
}

But wait! You’re probably wondering how in the world an item can be :checked when we’re removing pointer-events. Good question! We’re all finished with styling, so let’s move on to figuring some way to “select” an item purely through scrolling. In other words, whatever item scrolls into view and “snaps” into the container’s vertical center needs to behave like a typical form control selection. Yes, we’ll need JavaScript for that. 

let observer = new IntersectionObserver(entries => { 
  entries.forEach(entry => {
    with(entry) if(isIntersecting) target.children[1].checked = true;
  });
}, { 
  root: document.querySelector(`.scroll-container`), rootMargin: `-51% 0px -49% 0px`
});

document.querySelectorAll(`.scroll-item`).forEach(item => observer.observe(item));

The IntersectionObserver object is used to monitor (or “observe”) if and when an element (called a target) crosses through (or “intersects”) another element. That other element could be the viewport itself, but in this case, we’re observing the .scroll-container for when a .scroll-item intersects it. We’ve established the observed boundary with rootMargin:"-51% 0px -49% 0px".  

A callback function is executed when that happens, and we can use that to apply changes to the target element, which is the currently selected .scroll-item. In our case, we want to select a .scroll-item that is at the halfway mark in the .scroll-containertarget.children[1].checked = true.

That completes the code. Now, as we scroll through the items, whichever one snaps into the center position is the selected item. Here’s a look at the final demo again:

Let’s say that, instead of selecting an item that snaps into the .scroll-container‘s vertical center, the selection point we need to watch is the top of the container. No worries! All we do is update the scroll-snap-align property value from center to start in the CSS and remove the :first-of-type‘s top margin. From there, it’s only a matter of updating the scroll container’s background gradient so that the color stops highlight the top instead of the center. Like this:

And if one of the items has to be pre-selected when the page loads, we can get its position in JavaScript (getBoundingClientRect()) and use the scrollTo() method to scroll the container to where that specific item’s position is at the point of selection (which we’ll say is the center in keeping with our original demo). We’ll append a .selected class on that .scroll-item

<section class="scroll-container">
  <!-- more items -->
  <label class="scroll-items selected">
    2024
    <input type=radio name=items />
  </label>

  <!-- more items -->
</section>

Let’s select the .selected class, get its dimensions, and automatically scroll to it on page load:

let selected_item = (document.querySelector(".selected")).getBoundingClientRect();
let scroll_container = document.querySelector(".scroll-container");
scroll_container.scrollTo(0, selected_item.top - scroll_container.offsetHeight - selected_item.height);

It’s a little tough to demo this in a typical CodePen embed, so here’s a live demo in a GitHub Page (source code). I’ll drop a video in as well:

That’s it! You can build up this control or use it as a starting point to experiment with different layouts, styles, animations, and such. It’s important the UX clearly conveys to the users how the selection is done and which item is currently selected. And if I was doing this in a production environment, I’d want to make sure there’s a good fallback experience for when JavaScript might be unavailable and that my markup performs well on a screen reader.

References and further reading