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:
--itemHeightis the height of each item in the list.--itemGapis meant to be the space between two items.- The
--containerHeightvariable 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
--topBitand –-footBitvariables 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-container: target.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.
