Horizontal Scrolling Card Rows: Swipeable Lists That Stay in Their Lane
Streaming apps and shops have trained users to swipe sideways through rows of cards — "Continue watching", "Related products", "More from this author". On a phone, those rows save vertical space and let one screen hold several categories. On a desktop, the same content often reads better as a wrapping grid. The narrow problem here is building one row that scrolls horizontally when space is tight, wraps into a grid when space is generous, and never drags the rest of the page sideways. It is a lighter cousin of the CSS-only carousel, and part of Scroll Snap & Overflow Layouts in the Mastering Container Queries & Responsive Layouts guide.
Why a scrolling row, and when not
The choice between scrolling and wrapping is a content decision before it is a CSS decision. A scrolling row hides most of its items behind a gesture, which is acceptable when the items are interchangeable suggestions and costly when each item matters. Search results, the steps of a process and navigation should wrap. Browse lists, recommendations and category shelves can scroll.
The engineering argument for doing it in CSS is the same as for the carousel: native scrolling handles touch momentum, trackpads, wheels and keyboard input, and runs on the compositor. A card row does not even need the strict one-at-a-time behaviour of a carousel. Users skim these lists, so the snapping is gentler — proximity rather than mandatory — and multiple cards are visible at once.
The accessibility cost is manageable if the row is honest about being scrollable. A visible scrollbar or a clear peek of the next card is the difference between "I can see there is more" and "I thought that was everything".
The complete implementation
The row below scrolls with proximity snapping on narrow containers and turns into a wrapping grid once its container is wide enough to show everything. Drag the demo wider to watch the switch.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Scrolling card row</title>
<style>
body { font: 15px/1.5 system-ui, sans-serif; margin: 1.5rem; }
/* The wrapper is the query container. */
.shelf { container-type: inline-size; }
.row {
display: grid;
grid-auto-flow: column;
/* Cards are a fixed comfortable width; the row is as long as it needs. */
grid-auto-columns: 11rem;
gap: 0.75rem;
overflow-x: auto;
overscroll-behavior-inline: contain;
scroll-snap-type: inline proximity; /* gentle: snaps only when close */
scroll-padding-inline: 0.25rem;
padding: 0.25rem 0.25rem 0.75rem;
/* A thin, visible scrollbar: a cue that the row scrolls. */
scrollbar-width: thin;
scrollbar-color: #94a3b8 transparent;
}
.row:focus-visible { outline: 2px solid #2563eb; outline-offset: 2px; }
.card {
scroll-snap-align: start;
padding: 0.75rem;
border: 1px solid #cbd5e1;
border-radius: 10px;
}
.card__thumb {
aspect-ratio: 4 / 3;
border-radius: 6px;
background: linear-gradient(135deg, #e0e7ff, #a5b4fc);
}
.card h3 { font-size: 0.95rem; margin: 0.5rem 0 0; }
/* Wide container: stop scrolling and wrap into a grid instead. */
@container (width > 44rem) {
.row {
grid-auto-flow: row;
grid-template-columns: repeat(auto-fill, minmax(11rem, 1fr));
overflow-x: visible;
scroll-snap-type: none;
}
}
</style>
</head>
<body>
<section class="shelf" aria-labelledby="shelf-title">
<h2 id="shelf-title">More from this author</h2>
<div class="row" tabindex="0" role="group" aria-label="More from this author">
<article class="card"><div class="card__thumb"></div><h3>Night Trains</h3></article>
<article class="card"><div class="card__thumb"></div><h3>Harbour Lights</h3></article>
<article class="card"><div class="card__thumb"></div><h3>The Salt Road</h3></article>
<article class="card"><div class="card__thumb"></div><h3>Winter Orchard</h3></article>
<article class="card"><div class="card__thumb"></div><h3>Signal Hill</h3></article>
</div>
</section>
</body>
</html>
The switch is the part worth studying. The container query changes three things together: the flow direction (so the grid auto-places into rows), the track definition (so columns share the width), and the overflow and snap settings (so a wide row does not remain a scroll container with nothing to scroll). Leaving scroll-snap-type set on a non-scrolling element is harmless, but resetting it keeps the computed styles honest when debugging.
The key technique: containing overflow to the row
The most common bug with scrolling rows is not in the row at all. The row is correctly overflow-x: auto, but it sits inside a grid or flex ancestor whose item defaults to min-width: auto. That automatic minimum lets the ancestor grow to the row's full content width, so the row never overflows itself — the page does.
The fix is one declaration on whichever ancestor is a grid or flex item: min-width: 0, or minmax(0, 1fr) in the parent's track definition. Once the ancestor is constrained to the available width, the row's overflow-x: auto has something to overflow against, and the scrollbar appears where it belongs. The mechanism is the same automatic minimum explained in Preventing Flex and Grid Overflow, and it catches scrolling rows more often than any other component.
Sizing the cards for the gesture
Card width in a scrolling row is not only a visual choice; it decides how the row feels to scroll. Three guidelines hold up across devices.
First, choose a width that leaves a visible fraction of the next card at the narrowest container the row will appear in. On a 360-pixel phone with 1rem page padding, 11rem cards with a 0.75rem gap show about one and three-quarter cards, which is a clear cue. Cards of exactly half the row width are the worst case: two fit perfectly, the seam lines up with the edge, and nothing peeks.
Second, prefer a fixed rem width over a percentage for browse rows. A percentage makes each card a fraction of the row, so on a wide tablet the cards become huge and the row shows the same two items it showed on a phone. A rem width shows more items as space grows, which is the point of a browse list. Carousels are the exception, because there each slide is meant to dominate the view.
Third, keep interactive targets inside cards large enough to tap while the row is moving. Users often tap a card to stop momentum scrolling; a whole-card link with a generous hit area handles that gracefully, whereas small buttons inside a moving card are easy to miss. If a card must contain several actions, space them by at least the target spacing discussed in Target Size and Pointer Accessibility.
Finally, remember keyboard users. A focusable row scrolls with arrow keys, but tabbing through links inside the cards also scrolls each focused card into view automatically. Test both paths: arrow-key scrolling on the row itself, and Tab from card to card, checking that focused cards are never left half hidden behind a fade.
Variation: fading edges that hint at more
A soft fade at the row's trailing edge is a stronger "there is more" cue than a scrollbar, and it can be drawn with mask-image without any extra elements. The mask is a gradient from opaque to transparent over the last few rem.
.row {
/* Fade the trailing 3rem. A mask fades the content itself, so it works
on any background, unlike a gradient overlay in the page colour. */
mask-image: linear-gradient(
to right,
#000 calc(100% - 3rem),
transparent
);
}
/* In right-to-left text the trailing edge is on the left. */
.row:dir(rtl) {
mask-image: linear-gradient(to left, #000 calc(100% - 3rem), transparent);
}
/* Once the row wraps into a grid, nothing is hidden: remove the fade. */
@container (width > 44rem) {
.row { mask-image: none; }
}
A mask applies to the element's whole box, so the fade stays fixed at the right edge while content scrolls underneath it — which is exactly the desired effect. The limitation is that the fade remains even when the user has scrolled to the end. Scroll-driven animations can fix that by animating the mask's stop position with a scroll(self inline) timeline, so the fade disappears as the end approaches; the technique is covered in Scroll-Driven Animations and needs a static fallback where those animations are unsupported. The fade itself is decorative and must never be the only indication of focus or content.
Browser support
Scroll snap and overscroll-behavior are supported in all current engines. scrollbar-width and scrollbar-color are supported in current Firefox and Chromium-based browsers; Safari ignores them and draws its default overlay scrollbar, which is an acceptable fallback. mask-image is supported unprefixed in current versions of all three engines. The container query that switches between scrolling and wrapping needs Chrome and Edge 105+, Firefox 110+ or Safari 16+; without it the row simply scrolls at every width.
FAQ
Should I hide the scrollbar on a horizontal card row?
Not entirely. The scrollbar is one of the few visible cues that more content exists to the side, and hiding it removes that cue for mouse users who cannot swipe. Thin it with scrollbar-width and scrollbar-color instead, and add a visible peek of the next card.
When should a card row scroll instead of wrap? Scroll when the row is a secondary browse list whose items are equivalent, such as related articles or categories, and vertical space is precious. Wrap when every item matters and users should see them all without extra gestures, such as the main results of a search.
Why does my scrolling row make the whole page scroll sideways?
Usually a child has a fixed width wider than the row and the row is missing overflow-x: auto, or a grid or flex ancestor lets the row grow to its content width. Give the row overflow-x: auto and make sure its ancestors have min-width: 0 so it is constrained to the available width.
Does a scrolling row pass WCAG reflow? Yes, if the scrolling is contained within the component. WCAG 1.4.10 exempts content that requires two-dimensional layout for usage and meaning, and a row that scrolls inside itself does not make the page scroll horizontally. The page as a whole must still reflow at 320 CSS pixels.
Related
- Scroll Snap & Overflow Layouts — the parent guide to scrolling layout.
- CSS-Only Carousel With Scroll Snap — the one-slide-at-a-time variant.
- auto-fit and minmax() Grids — the wrapping layout the row switches to.
- Mask Image Fade Edges — more on masks, including animated fades.
Related articles
More pages in the same section.