CSS Scroll Shelf
A horizontally scrollable content shelf with scroll-snap for clean stopping points.
A CSS scroll shelf is a row of fixed width cards that overflows sideways and stops cleanly at each card. Three declarations build it: display: flex puts the cards in a row, overflow-x: auto makes that row a scroll container, and scroll-snap-type: x mandatory with scroll-snap-align: start on each card decides where a scroll gesture comes to rest.
The difference between a shelf and a scroll snap carousel is what the cards are sized against. A carousel sizes each slide to the container so exactly one shows at a time. A shelf gives each card a fixed pixel width, so however wide the viewport is, the reader always sees several cards and part of another. That partial card at the edge is the whole point: it is what tells someone there is more to the right.
A rail of fixed width cards
← scroll or swipe →
HTML
<div class="shelf-wrap">
<div class="shelf">
<div class="shelf-item">
<div class="shelf-thumb"></div>
<div class="shelf-body">
<div class="shelf-title">Design Systems</div>
<div class="shelf-sub">12 articles</div>
</div>
</div>
<div class="shelf-item">
<div class="shelf-thumb"></div>
<div class="shelf-body">
<div class="shelf-title">CSS Tricks</div>
<div class="shelf-sub">8 articles</div>
</div>
</div>
<div class="shelf-item">
<div class="shelf-thumb"></div>
<div class="shelf-body">
<div class="shelf-title">Typography</div>
<div class="shelf-sub">15 articles</div>
</div>
</div>
<div class="shelf-item">
<div class="shelf-thumb"></div>
<div class="shelf-body">
<div class="shelf-title">Animation</div>
<div class="shelf-sub">9 articles</div>
</div>
</div>
<div class="shelf-item">
<div class="shelf-thumb"></div>
<div class="shelf-body">
<div class="shelf-title">Layout</div>
<div class="shelf-sub">20 articles</div>
</div>
</div>
<div class="shelf-item">
<div class="shelf-thumb"></div>
<div class="shelf-body">
<div class="shelf-title">Performance</div>
<div class="shelf-sub">6 articles</div>
</div>
</div>
<div class="shelf-item">
<div class="shelf-thumb"></div>
<div class="shelf-body">
<div class="shelf-title">Accessibility</div>
<div class="shelf-sub">11 articles</div>
</div>
</div>
</div>
<p class="shelf-hint">← scroll or swipe →</p>
</div>
CSS
/* Capped so the rail always overflows. Seven cards at 160px plus gaps come
to roughly 1190px, so on a wide screen the shelf had nothing to scroll
and the snapping was impossible to see. */
.shelf-wrap {
position: relative;
width: 100%;
max-width: 620px;
}
.shelf {
display: flex;
gap: .75rem;
overflow-x: auto;
scroll-snap-type: x mandatory;
scroll-behavior: smooth;
padding-bottom: .5rem;
-webkit-overflow-scrolling: touch;
}
.shelf::-webkit-scrollbar {
height: 4px;
}
.shelf::-webkit-scrollbar-track {
background: #1c1c1e;
border-radius: 2px;
}
.shelf::-webkit-scrollbar-thumb {
background: #2a2a2d;
border-radius: 2px;
}
.shelf::-webkit-scrollbar-thumb:hover {
background: #88888f;
}
/* Fixed-width cards that refuse to shrink are what makes the row overflow */
.shelf-item {
flex: 0 0 160px;
scroll-snap-align: start;
background: #141415;
border: 1px solid #2a2a2d;
border-radius: 12px;
overflow: hidden;
transition: border-color .2s;
}
.shelf-item:hover {
border-color: #b8ff57;
}
.shelf-item:nth-child(1) .shelf-thumb { background: linear-gradient(135deg, #c084fc, #38bdf8); }
.shelf-item:nth-child(2) .shelf-thumb { background: linear-gradient(135deg, #b8ff57, #57d9a3); }
.shelf-item:nth-child(3) .shelf-thumb { background: linear-gradient(135deg, #ff6b6b, #ff9040); }
.shelf-item:nth-child(4) .shelf-thumb { background: linear-gradient(135deg, #f472b6, #c084fc); }
.shelf-item:nth-child(5) .shelf-thumb { background: linear-gradient(135deg, #38bdf8, #57d9a3); }
.shelf-item:nth-child(6) .shelf-thumb { background: linear-gradient(135deg, #ff9040, #ffd444); }
.shelf-item:nth-child(7) .shelf-thumb { background: linear-gradient(135deg, #57d9a3, #b8ff57); }
.shelf-thumb {
height: 90px;
background-size: cover;
background-position: center;
}
.shelf-body {
padding: .6rem .75rem;
}
.shelf-title {
font-size: .85rem;
font-weight: 600;
margin-bottom: .15rem;
}
.shelf-sub {
font-size: .75rem;
color: #88888f;
}
.shelf-hint {
text-align: center;
font-size: .75rem;
color: #88888f;
margin: .5rem 0 0;
}
Other ways to build it
Faded edges instead of a hint
A gradient mask-image makes the rail transparent for the last few pixels at each end, so cards dissolve rather than being cut off by a hard border. It reads as more content in that direction, which is the job the text hint was doing underneath. The mask is on the scroll container itself, so it stays put while the cards move through it. The gradient is written in the mask's own coordinate space: opaque black in the middle, transparent at both ends, and the transparency is what hides the pixels.
HTML
<div class="shelf-wrap">
<div class="shelf shelf-faded">
<div class="shelf-item"><div class="shelf-thumb"></div><div class="shelf-body"><div class="shelf-title">Design Systems</div><div class="shelf-sub">12 articles</div></div></div>
<div class="shelf-item"><div class="shelf-thumb"></div><div class="shelf-body"><div class="shelf-title">CSS Tricks</div><div class="shelf-sub">8 articles</div></div></div>
<div class="shelf-item"><div class="shelf-thumb"></div><div class="shelf-body"><div class="shelf-title">Typography</div><div class="shelf-sub">15 articles</div></div></div>
<div class="shelf-item"><div class="shelf-thumb"></div><div class="shelf-body"><div class="shelf-title">Animation</div><div class="shelf-sub">9 articles</div></div></div>
<div class="shelf-item"><div class="shelf-thumb"></div><div class="shelf-body"><div class="shelf-title">Layout</div><div class="shelf-sub">20 articles</div></div></div>
<div class="shelf-item"><div class="shelf-thumb"></div><div class="shelf-body"><div class="shelf-title">Performance</div><div class="shelf-sub">6 articles</div></div></div>
<div class="shelf-item"><div class="shelf-thumb"></div><div class="shelf-body"><div class="shelf-title">Accessibility</div><div class="shelf-sub">11 articles</div></div></div>
</div>
</div>
CSS
.shelf-faded {
display: flex;
gap: .75rem;
overflow-x: auto;
scroll-snap-type: x mandatory;
scrollbar-width: none;
/* transparent at both ends, opaque in between. the mask is on the
container, so it holds still while the cards move through it */
-webkit-mask-image: linear-gradient(to right,
transparent 0, #000 28px, #000 calc(100% - 28px), transparent 100%
);
mask-image: linear-gradient(to right,
transparent 0, #000 28px, #000 calc(100% - 28px), transparent 100%
);
}
.shelf-faded::-webkit-scrollbar {
display: none;
}
A vertical shelf
Nothing about the technique is horizontal. Swapping overflow-x for overflow-y, the flex direction to column, and the snap axis to y gives a vertical rail that stops on each card. The one change that is easy to miss is the sizing: flex: 0 0 92px now fixes the card's height rather than its width, because flex-basis always refers to the main axis and the main axis has rotated.
HTML
<div class="shelf-vertical-wrap">
<div class="shelf-vertical">
<div class="shelf-item shelf-row"><div class="shelf-thumb"></div><div class="shelf-body"><div class="shelf-title">Design Systems</div><div class="shelf-sub">12 articles</div></div></div>
<div class="shelf-item shelf-row"><div class="shelf-thumb"></div><div class="shelf-body"><div class="shelf-title">CSS Tricks</div><div class="shelf-sub">8 articles</div></div></div>
<div class="shelf-item shelf-row"><div class="shelf-thumb"></div><div class="shelf-body"><div class="shelf-title">Typography</div><div class="shelf-sub">15 articles</div></div></div>
<div class="shelf-item shelf-row"><div class="shelf-thumb"></div><div class="shelf-body"><div class="shelf-title">Animation</div><div class="shelf-sub">9 articles</div></div></div>
<div class="shelf-item shelf-row"><div class="shelf-thumb"></div><div class="shelf-body"><div class="shelf-title">Layout</div><div class="shelf-sub">20 articles</div></div></div>
</div>
</div>
CSS
.shelf-vertical {
display: flex;
flex-direction: column;
gap: .75rem;
max-height: 230px;
overflow-y: auto;
scroll-snap-type: y mandatory;
}
/* flex-basis follows the main axis, so 92px is now a height */
.shelf-row {
flex: 0 0 92px;
scroll-snap-align: start;
display: flex;
align-items: center;
gap: .75rem;
}
.shelf-row .shelf-thumb {
flex: 0 0 92px;
height: 100%;
}
How it works
overflow-x: auto on the container enables horizontal scrolling. display: flex makes child items sit in a row. scroll-snap-type: x mandatory on the container and scroll-snap-align: start on each item ensures the scroll stops cleanly at item boundaries. -webkit-overflow-scrolling: touch enables smooth momentum scrolling on iOS.
flex: 0 0 160px is doing more work than it looks. The 0 0 half is what matters: do not grow, do not shrink. Leave the shrink factor at its default of 1 and flexbox compresses every card until the whole row fits, at which point the container no longer overflows and the shelf has nothing to scroll. A shelf that mysteriously refuses to scroll on a wide screen is almost always a missing flex-shrink: 0.
gap and scroll snap interact in a way that is easy to get wrong. With scroll-snap-align: start the snap point is the card's leading edge, so the gap sits outside it and each stop lands the card flush against the container edge. Add padding-inline to the container and the snap point stays where it was, tucking the card under the padding, unless scroll-padding-inline moves the snap line to match.
Scrollbar styling has two separate mechanisms. scrollbar-width and scrollbar-color are the standard properties and they are recent. ::-webkit-scrollbar and its family of sub pseudo-elements are non-standard, far older, and offer much finer control, which is why the stylesheet here uses them to give the shelf a 4 pixel rail. Writing both is normal. Writing only the webkit ones leaves Firefox with a default scrollbar, which is a cosmetic difference rather than a broken layout.
-webkit-overflow-scrolling: touch is in this stylesheet for historical reasons. Older iOS versions scrolled overflow containers without momentum unless the property was set, and current iOS gives momentum by default and ignores it. It is harmless to leave in place and it is no longer doing anything.
scroll-behavior: smooth on the container affects programmatic scrolling and fragment navigation, not the reader's own gesture. A wheel or swipe is always immediate. What it changes is what happens when a link points at a card's id, which is how you build previous and next controls for a shelf without any JavaScript at all.
CSS properties used
flex0 0 160pxfixes each card's width and stops flexbox shrinking the row to fit. The zero shrink factor is the part that makes the shelf overflow.scroll-snap-typex mandatoryon the container. Every gesture ends on a snap point rather than wherever the momentum ran out.scroll-snap-alignstarton each card, so a stop aligns the card's leading edge with the container's leading edge.gap- Spaces the cards. It sits outside the snap point, so a snapped card lands flush and the gap appears after it.
scrollbar-width- The standard way to thin or hide a scrollbar. Pairs with the older
::-webkit-scrollbarrules for finer control. mask-image- A gradient mask fades the rail out at its edges, which suggests more content without adding a gradient element over the top.
Browser support
| Feature | Chrome | Firefox | Safari | Edge |
|---|---|---|---|---|
scroll-snap | 69 | 68 | 11 | 79 |
flexbox | 21 | 28 | 6.1 | 12 |
gap (flexbox) | 84 | 63 | 14.1 | 84 |
mask-image | 120 | 53 | 15.4 | 120 |
scrollbar-width | 121 | 64 | 26.2 | 121 |
gap on a flex container is much newer than flexbox itself, which is why older shelves used a margin on every card and a negative margin on the container to cancel the last one. The mask and scrollbar rows are the newest in the table and both are cosmetic: without the mask the rail simply has hard edges, and without scrollbar-width it shows a normal scrollbar. Neither absence breaks the scrolling or the snapping.
Accessibility notes
The container is focusable in most browsers once it overflows, so arrow keys, Home and End move it without any script. Keep the scrolling element and the focusable element the same thing. Wrapping the cards in an inner div that scrolls instead pushes the focusable element somewhere the reader cannot see it, and the focus ring lands nowhere useful.
Cards that scroll off the edge stay in the accessibility tree and in the tab order, which is correct and occasionally surprising. Tabbing through a shelf of twenty cards means twenty stops before reaching whatever follows it. Where a shelf is long, a heading before it and a skip link past it are worth more than any amount of visual polish.
A hidden scrollbar removes the only desktop affordance the shelf has. Leaving part of the next card visible replaces it, which is what fixed width cards do naturally as long as the container width is not an exact multiple of the card width. The edge fade in the first variant makes that cue explicit.
scroll-snap-type: mandatory should be reserved for cards of equal size. If one card is taller or wider than the rest the browser still forces a stop on it, and content can be pulled back out of view while the reader is looking at it.
What you can build with it
- Category rails. The row of covers on a streaming or storefront home page, where each rail is a genre and the cards are titles.
- Related articles. A strip of post cards under an article, which takes one row of height instead of the four a grid would need.
- Image strips. Thumbnails under a main image, where clicking one opens the CSS lightbox at full size.
- Filter chips. A row of category buttons that scrolls on a narrow screen instead of wrapping to three lines.
- Step by step guides. Numbered cards a reader moves through sideways, with the snap making each step land in the same place.
Mistakes worth avoiding
- Leaving
flex-shrinkat its default. Every card is squeezed until the row fits, the container stops overflowing, and nothing scrolls. - Sizing cards as a percentage of the container. That turns the shelf into a carousel, because the number of visible cards stops changing with the viewport.
- Adding container padding without
scroll-padding. Each snapped card lands under the padding rather than inside it, so the first card looks clipped at every stop. - Hiding the scrollbar with nothing to replace it. On a desktop with no touchscreen there is then no visible indication the rail moves at all.
- Styling only
::-webkit-scrollbar. Firefox keeps its default scrollbar, which is usually thicker than the design allows for and changes the rail's height.
Frequently asked questions
How do you make a horizontal scrolling row in CSS?
display: flex with overflow-x: auto, and give each child flex: 0 0 <width>. The zero shrink factor is what keeps the cards at their width so the row overflows. Add scroll-snap-type: x mandatory and scroll-snap-align: start for clean stopping points.Why does my horizontal shelf not scroll?
flex-shrink: 0 on the cards, and check that the container has a width limit of its own rather than growing to fit its contents.How do you hide the scrollbar but keep scrolling?
scrollbar-width: none for the standard property and ::-webkit-scrollbar { display: none } for the older non-standard one. Both are cosmetic and neither disables the scrolling. Add a visible cue in its place, because on a desktop the scrollbar was the only sign the rail moves.