A foundational overview of how to build a responsive horizontal scrollview for TVs, phones, desktops, etc.
In this post I want to share thinking on ways to create horizontal scrolling experiences for the web that are minimal, responsive, accessible and work across browsers and platforms (like TVs!). Try the demo.
If you prefer video, here's a YouTube version of this post:
Overview
We'll be building a horizontal scroll layout meant for hosting thumbnails of
media or products. The component begins as a humble <ul>
list but is
transformed with CSS into a satisfying and smooth scroll experience, showcasing
images and snapping them to a grid. JavaScript is added to facilitate
roving-index interactions, helping keyboard users skip traversing 100+ items.
Plus an experimental media query, prefers-reduced-data
, is used to turn the
media scroller into a lightweight title scroller experience.
Start with accessible markup
A media scroller is made of just a couple of core components, a list with items. A list, in its simplest form, can travel all over the world and be clearly consumed by all. A user landing at this page can browse a list and click a link to view an item. This is our accessible base.
Deliver a list with a <ul>
element:
<ul class="horizontal-media-scroller">
<li></li>
<li></li>
<li></li>
...
<ul>
Make the list items interactive with an <a>
element:
<li>
<a href="#">
...
</a>
</li>
Use a <figure>
element to semantically represent an image and its caption:
<figure>
<picture>
<img alt="..." loading="lazy" src="https://picsum.photos/500/500?1">
</picture>
<figcaption>Legends</figcaption>
</figure>
Notice the alt
and loading
attributes on the <img>
. Alt text for a media
scroller is a UX opportunity to help bring the thumbnail extra context, or as
fallback text if the image didn't load, or it provides a spoken UI for users
relying on assistive technology like a screen reader. Learn more with Five golden
rules for compliant alt
text.
The loading
attribute accepts the keyword lazy
as a way to signal this image
source should be fetched only when the image is within the viewport. This can be
really nice for large lists, as users will only download images for items they
scrolled into view.
Support the user's color scheme preference
Use color-scheme
as a <meta>
tag to signal to the browser that your page
wants both the light and dark provided user-agent styles. It's a free dark mode
or light mode, depending on how you look at it:
<meta name="color-scheme" content="dark light">
The meta tag provides the earliest signal possible, so the browser can select a dark default canvas color if the user has a dark theme preference. This means that navigations between pages of the site won't flash a white canvas background between loads. Seamless dark theme between loads, much nicer on the eyes.
Learn much more from Thomas Steiner at https://web.dev/color-scheme/.
Add content
Given the above content structure of ul > li > a > figure > picture > img
,
the next task is to add images and titles to scroll through. I've packed the demo with
static placeholder images and text, but feel free to power this from your
favorite data source.
Add style with CSS
Now it's time for CSS to take this generic list of content and turn it into an experience. Netflix, App stores and many more sites and apps use horizontal scrolling areas to pack the viewport with categories and options.
Creating the scroller layout
It's important to avoid cutting off content in layouts or leaning on text truncation with ellipsis. Many television sets have media scrollers just like this one, but all too often resort to ellipsing content. This layout does not! It also lets the media content override the column size, making 1 layout flexible enough to handle many interesting combinations.
The container allows overriding the column size by providing the default size as a custom property. This grid layout is opinionated about column size, it's managing spacing and direction only:
.horizontal-media-scroller {
--size: 150px;
display: grid;
grid-auto-flow: column;
gap: calc(var(--gap) / 2); /* parent owned value for children to be relative to*/
margin: 0;
}
The custom property is then used by the <picture>
element to create our base aspect ratio: a box:
.horizontal-media-scroller {
--size: 150px;
display: grid;
grid-auto-flow: column;
gap: calc(var(--gap) / 2);
margin: 0;
& picture {
inline-size: var(--size);
block-size: var(--size);
}
}
With only a few more minor styles, complete the barebones of the media scroller:
.horizontal-media-scroller {
--size: 150px;
display: grid;
grid-auto-flow: column;
gap: calc(var(--gap) / 2);
margin: 0;
overflow-x: auto;
overscroll-behavior-inline: contain;
& > li {
display: inline-block; /* removes the list-item bullet */
}
& picture {
inline-size: var(--size);
block-size: var(--size);
}
}
Setting overflow
sets the <ul>
up to allow scrolling and keyboard navigation
through its list, then each direct child <li>
element has its ::marker
removed
by getting a new display type of inline-block
.
The images aren't responsive yet though, and burst right out of the boxes they're inside. Tame them with some sizes, fit, and border styles, and a background gradient for when they're lazy loading:
img {
/* smash into whatever box it's in */
inline-size: 100%;
block-size: 100%;
/* don't squish but do cover the space */
object-fit: cover;
/* soften the edges */
border-radius: 1ex;
overflow: hidden;
/* if empty, show a gradient placeholder */
background-image:
linear-gradient(
to bottom,
hsl(0 0% 40%),
hsl(0 0% 20%)
);
}
Scroll padding
Alignment to page content, plus an edge-to-edge scrolling surface area, are critical to a harmonious and minimal component.
To accomplish the edge-to-edge scroll layout which aligns with our typography
and layout lines, use padding
that matches the scroll-padding
:
.horizontal-media-scroller {
--size: 150px;
display: grid;
grid-auto-flow: column;
gap: calc(var(--gap) / 2);
margin: 0;
overflow-x: auto;
overscroll-behavior-inline: contain;
padding-inline: var(--gap);
scroll-padding-inline: var(--gap);
padding-block: calc(var(--gap) / 2); /* make space for scrollbar and focus outline */
}
Horizontal scroll padding bug fix The above shows how easy it should be to pad a scroll container, but there's outstanding compatibility issues with it (fixed in Chromium 91+ though!). See here for a bit of the history, but the short version is that padding wasn't always accounted for in a scroll view.
To trick browsers into putting the padding at the end of the scroller I'll target the last figure in each list and append a pseudo element that's the amount of padding desired.
.horizontal-media-scroller > li:last-of-type figure {
position: relative;
&::after {
content: "";
position: absolute;
inline-size: var(--gap);
block-size: 100%;
inset-block-start: 0;
inset-inline-end: calc(var(--gap) * -1);
}
}
Using logical properties enables the media scroller to work in any writing mode and document direction.
Scroll snapping
A scrolling container with overflow can become a snapping viewport with one line of CSS, then it's on children to specify how they'd like to align with that viewport.
.horizontal-media-scroller {
--size: 150px;
display: grid;
grid-auto-flow: column;
gap: calc(var(--gap) / 2);
margin: 0;
overflow-x: auto;
overscroll-behavior-inline: contain;
padding-inline: var(--gap);
scroll-padding-inline: var(--gap);
padding-block-end: calc(var(--gap) / 2);
scroll-snap-type: inline mandatory;
& figure {
scroll-snap-align: start;
}
}
Focus
The inspiration for this component comes from its massive popularity on TVs, in App Stores, and more. Many video game platforms use a media scroller very similar to this one, as their primary home screen layout. Focus is a huge UX moment here, not just a small addition. Imagine using this media scroller from your couch with a remote, give that interaction some small enhancements:
.horizontal-media-scroller a {
outline-offset: 12px;
&:focus {
outline-offset: 7px;
}
@media (prefers-reduced-motion: no-preference) {
& {
transition: outline-offset .25s ease;
}
}
}
This sets the focus outline style 7px
away from the box, giving it some nice
space. If the user has no motion preferences around reducing motion, the offset
is transitioned, giving subtle motion to the focus event.
Roving index
Gamepad and keyboard users need special attention in these longs lists of scrolling content and options. The common pattern for solving this is called roving index. It's when a container of items is keyboard focused but only 1 child is allowed to hold focus at a time. This single focusable item at a time experience is designed to allow bypassing the potentially long list of items, as opposed to pressing tab 50+ times to reach the end.
There's 300 items in that first scroller of the demo. We can do better than make them traverse all of them to reach the next section.
To create this experience, JavaScript needs to observe keyboard events and focus events. I created a small open source library on npm to help make this user experience easy to achieve. Here's how to use it for the 3 scrollers:
import {rovingIndex} from 'roving-ux';
rovingIndex({
element: someElement
});
This demo queries the document for the scrollers and for each of them calls the
rovingIndex()
function. Pass the rovingIndex()
the element to get the roving
experience, like a list container, and a target query selector, in case the
focus targets aren't direct descendants.
document.querySelectorAll('.horizontal-media-scroller')
.forEach(scroller =>
rovingIndex({
element: scroller,
target: 'a',
}))
To learn more about this effect, see the open source library roving-ux.
Aspect-ratio
As of writing this post, the support for
aspect-ratio
is behind a
flag in Firefox but available in Chromium browsers or set top boxes. Since the
media scroller grid layout only specifies direction and spacing, the sizing can
change inside a media query which feature checks for aspect-ratio support.
Progressive enhancement into some more dynamic media scrollers.
@supports (aspect-ratio: 1) {
.horizontal-media-scroller figure > picture {
inline-size: auto; /* for a block-size driven ratio */
aspect-ratio: 1; /* boxes by default */
@nest section:nth-child(2) & {
aspect-ratio: 16/9;
}
@nest section:nth-child(3) & {
/* double the size of the others */
block-size: calc(var(--size) * 2);
aspect-ratio: 4/3;
/* adjust size to fit more items into the viewport */
@media (width <= 480px) {
block-size: calc(var(--size) * 1.5);
}
}
}
}
If the browser supports aspect-ratio
syntax, the media scroller pictures are
upgraded to aspect-ratio
sizing. Using the draft nesting syntax, each picture
changes its aspect ratio depending if it's the first, second, or third rows. The
nest syntax also allows setting some small
viewport adjustments, right there with the other sizing logic.
With that CSS, as the feature is available in more browser engines, an easy to manage but more visually appealing layout will render.
Prefers reduced data
While this next technique is only available
behind a flag in
Canary,
I wanted to share how I could save a considerable amount of page load time and
data use with a few lines of CSS. The prefers-reduced-data
media query from
level 5 allows asking if the device is in
any reduced data states, like a data saver mode. If it is, I can modify the
document, and in this case, hide the images.
figure {
@media (prefers-reduced-data: reduce) {
& {
min-inline-size: var(--size);
& > picture {
display: none;
}
}
}
}
The content is still navigable but without the cost of the heavy images being
downloaded. Here's the site before adding the prefers-reduced-data
CSS:
(7 requests, 100kb of resources in 131ms)
Here's the site performance after adding the prefers-reduced-data
CSS:
(71 requests, 1.2mb of resources in 1.07s)
64 fewer requests, that would be the ~60 images within the viewport (tests taken on a wide screen display) of this browser tab, a page load boost of ~80%, and 10% of the data over the wire. Pretty powerful CSS.
Conclusion
Now that you know how I did it, how would you?! 🙂
Let's diversify our approaches and learn all the ways to build on the web. Create a Codepen or host your own demo, tweet me with it, and I'll add it to the Community remixes section below.
Source
Community remixes
Nothing to see here yet!