Scroller
.scroller is the third answer to "what happens when a row runs out of
room". A cluster wraps, a .bar squashes, and a
scroller keeps everything on one line and lets the reader scroll sideways — with
snapping, a hidden scrollbar, and a bleed that runs the row to the edge of the
screen.
When to use it
Use it when there are more items than fit and they are all peers: a row of date tiles, category chips, recent files, a carousel of cards. It works best when the items are wide enough that a partial one is visible at the edge, because that sliver is the only thing telling the reader there is more.
Monday
3 bookings
Tuesday
4 bookings
Wednesday
5 bookings
Thursday
6 bookings
Friday
7 bookings
Saturday
8 bookings
Sunday
9 bookings
Next week
10 bookings
<div class="scroller">
<div class="card" style="inline-size:12rem">
<div class="card-body">
<h3 class="card-title">Monday</h3>
<p class="text-muted text-sm">3 bookings</p>
</div>
</div>
<div class="card" style="inline-size:12rem">
<div class="card-body">
<h3 class="card-title">Tuesday</h3>
<p class="text-muted text-sm">4 bookings</p>
</div>
</div>
<div class="card" style="inline-size:12rem">
<div class="card-body">
<h3 class="card-title">Wednesday</h3>
<p class="text-muted text-sm">5 bookings</p>
</div>
</div>
<div class="card" style="inline-size:12rem">
<div class="card-body">
<h3 class="card-title">Thursday</h3>
<p class="text-muted text-sm">6 bookings</p>
</div>
</div>
<div class="card" style="inline-size:12rem">
<div class="card-body">
<h3 class="card-title">Friday</h3>
<p class="text-muted text-sm">7 bookings</p>
</div>
</div>
<div class="card" style="inline-size:12rem">
<div class="card-body">
<h3 class="card-title">Saturday</h3>
<p class="text-muted text-sm">8 bookings</p>
</div>
</div>
<div class="card" style="inline-size:12rem">
<div class="card-body">
<h3 class="card-title">Sunday</h3>
<p class="text-muted text-sm">9 bookings</p>
</div>
</div>
<div class="card" style="inline-size:12rem">
<div class="card-body">
<h3 class="card-title">Next week</h3>
<p class="text-muted text-sm">10 bookings</p>
</div>
</div>
</div>
<div class="scroller">
<div class="card" style="inline-size:12rem">
<div class="card-body">
<h3 class="card-title">Monday</h3>
<p class="text-muted text-sm">3 bookings</p>
</div>
</div>
<div class="card" style="inline-size:12rem">
<div class="card-body">
<h3 class="card-title">Tuesday</h3>
<p class="text-muted text-sm">4 bookings</p>
</div>
</div>
<div class="card" style="inline-size:12rem">
<div class="card-body">
<h3 class="card-title">Wednesday</h3>
<p class="text-muted text-sm">5 bookings</p>
</div>
</div>
<div class="card" style="inline-size:12rem">
<div class="card-body">
<h3 class="card-title">Thursday</h3>
<p class="text-muted text-sm">6 bookings</p>
</div>
</div>
<div class="card" style="inline-size:12rem">
<div class="card-body">
<h3 class="card-title">Friday</h3>
<p class="text-muted text-sm">7 bookings</p>
</div>
</div>
<div class="card" style="inline-size:12rem">
<div class="card-body">
<h3 class="card-title">Saturday</h3>
<p class="text-muted text-sm">8 bookings</p>
</div>
</div>
<div class="card" style="inline-size:12rem">
<div class="card-body">
<h3 class="card-title">Sunday</h3>
<p class="text-muted text-sm">9 bookings</p>
</div>
</div>
<div class="card" style="inline-size:12rem">
<div class="card-body">
<h3 class="card-title">Next week</h3>
<p class="text-muted text-sm">10 bookings</p>
</div>
</div>
</div>
What the eight declarations do
A flex row whose items do not shrink
.scroller > * gets flex: 0 0 auto. Without it the
items would compress to fit rather than overflow, and there would be nothing to
scroll — which is the mistake that makes a "horizontal scroller" quietly behave
like a squashed bar.
Snapping, proximity rather than mandatory
scroll-snap-type: x proximity with
scroll-snap-align: start on each child. Proximity means the row
settles onto an item when the reader stops near one, and leaves them alone
otherwise. mandatory would force a snap on every scroll, which fights
a reader who is trying to move a small distance and can trap them if an item is
taller than the viewport.
The page does not scroll with it
overscroll-behavior-inline: contain stops a horizontal scroll that
reaches the end of the row from turning into a browser back-navigation or a scroll
of the page behind it. On a trackpad, without this, flicking past the last card
navigates away from the page.
The bleed
margin-inline: calc(var(--space-gutter) * -1);
padding-inline: var(--space-gutter);
A negative margin the width of the page gutter, cancelled by an equal padding. The row's scrolling area therefore reaches the edge of the screen while its content still lines up with everything else in the container. On a phone that is the difference between cards that look cut off and cards that look like they continue past the edge.
It only works inside something that has the gutter — a .container. In
a container with different padding the row will bleed by the wrong amount, and in
an element with overflow: hidden it will be clipped.
The hidden scrollbar
scrollbar-width: none and a
::-webkit-scrollbar { display: none }. This is the most
questionable line in the component, and it is discussed properly under
Accessibility — hiding the scrollbar removes the only
persistent signal that the row scrolls at all.
Gap
Same --gap as the other primitives, defaulting to
.75rem, so the
spacing scale works here too.
Monday
3 bookings
Tuesday
4 bookings
Wednesday
5 bookings
Thursday
6 bookings
Friday
7 bookings
Saturday
8 bookings
<div class="scroller stack-6">
<div class="card" style="inline-size:12rem">
<div class="card-body">
<h3 class="card-title">Monday</h3>
<p class="text-muted text-sm">3 bookings</p>
</div>
</div>
<div class="card" style="inline-size:12rem">
<div class="card-body">
<h3 class="card-title">Tuesday</h3>
<p class="text-muted text-sm">4 bookings</p>
</div>
</div>
<div class="card" style="inline-size:12rem">
<div class="card-body">
<h3 class="card-title">Wednesday</h3>
<p class="text-muted text-sm">5 bookings</p>
</div>
</div>
<div class="card" style="inline-size:12rem">
<div class="card-body">
<h3 class="card-title">Thursday</h3>
<p class="text-muted text-sm">6 bookings</p>
</div>
</div>
<div class="card" style="inline-size:12rem">
<div class="card-body">
<h3 class="card-title">Friday</h3>
<p class="text-muted text-sm">7 bookings</p>
</div>
</div>
<div class="card" style="inline-size:12rem">
<div class="card-body">
<h3 class="card-title">Saturday</h3>
<p class="text-muted text-sm">8 bookings</p>
</div>
</div>
</div>
<div class="scroller stack-6">
<div class="card" style="inline-size:12rem">
<div class="card-body">
<h3 class="card-title">Monday</h3>
<p class="text-muted text-sm">3 bookings</p>
</div>
</div>
<div class="card" style="inline-size:12rem">
<div class="card-body">
<h3 class="card-title">Tuesday</h3>
<p class="text-muted text-sm">4 bookings</p>
</div>
</div>
<div class="card" style="inline-size:12rem">
<div class="card-body">
<h3 class="card-title">Wednesday</h3>
<p class="text-muted text-sm">5 bookings</p>
</div>
</div>
<div class="card" style="inline-size:12rem">
<div class="card-body">
<h3 class="card-title">Thursday</h3>
<p class="text-muted text-sm">6 bookings</p>
</div>
</div>
<div class="card" style="inline-size:12rem">
<div class="card-body">
<h3 class="card-title">Friday</h3>
<p class="text-muted text-sm">7 bookings</p>
</div>
</div>
<div class="card" style="inline-size:12rem">
<div class="card-body">
<h3 class="card-title">Saturday</h3>
<p class="text-muted text-sm">8 bookings</p>
</div>
</div>
</div>
padding-block-end: .5rem
is set as well, leaving room for a scrollbar on platforms that show one over the
content rather than beside it.
A row of chips
The other common use. Here the items are small, so the sliver at the edge is less obvious and the case for a visible affordance is stronger.
<div class="scroller cluster-tight">
<span class="chip">All</span>
<span class="chip">Overdue</span>
<span class="chip">This week</span>
<span class="chip">Unassigned</span>
<span class="chip">Archived</span>
<span class="chip">Drafts</span>
<span class="chip">Shared with me</span>
</div>
<div class="scroller cluster-tight">
<span class="chip">All</span>
<span class="chip">Overdue</span>
<span class="chip">This week</span>
<span class="chip">Unassigned</span>
<span class="chip">Archived</span>
<span class="chip">Drafts</span>
<span class="chip">Shared with me</span>
</div>
.cluster-tight works on a scroller because both read
--gap. It is also a good illustration of the naming muddle: the class is
called cluster-tight and there is no cluster here.
Classes
.scroller is a single class rather than a component root — the
extractor finds no .scroller-* members — so there is no completeness
check on this page. The rules that shape it are
.scroller > * and .scroller::-webkit-scrollbar, neither
of which is a class of its own.
| Class | Layer | What it does | Source |
|---|---|---|---|
.scroller |
deck.layout |
Everything stays on one line and scrolls sideways — cards, chips, dates | 04-layout.css:164 |
Tokens
| Token | Live | Declared value |
|---|---|---|
--space-2 |
.5rem |
|
--space-3 |
.75rem |
|
--space-gutter |
clamp(1rem, .55rem + 2.2vw, 2rem) |
Accessibility
- The hidden scrollbar is a real cost. A scrollbar is the only persistent, universal indication that a region scrolls. Hiding it means the affordance is now the partially-visible item at the edge, and if your items happen to fit the width exactly there is no affordance at all. Deck hides it because a native scrollbar under a row of cards looks like a mistake on desktop; that is an aesthetic decision overriding a usability one, and it should be made knowingly.
-
A scroll container needs to be focusable to be keyboard-scrollable.
.scrollersets notabindex, so in Chromium a keyboard-only user cannot scroll it unless it contains something focusable. If your items are links or buttons, focus moves into them and the row scrolls to follow — that case works. If the items are not interactive, addtabindex="0"and arole="region"with anaria-label. This is the same defect as.table-wrap, and it is recorded inFINDINGS.md. -
Do not add
aria-hiddento the off-screen items. They are scrolled, not hidden. Everything in the row is in the accessibility tree and in the tab order, which is correct. -
Snapping and reduced motion. Snapping is positional rather than
animated, so it is unaffected by
prefers-reduced-motion— butscroll-behavior: smooth, if you add it, is affected, and the global reset turns it off. -
Proximity snapping is the accessible choice.
mandatorycan prevent a reader from resting between items, and with a zoomed page an item wider than the viewport can become impossible to scroll past. - The bleed does not break reflow. The negative margin is cancelled by an equal padding, so the row never makes the page wider than the viewport — the overflow is inside the scroller, which is the point.
Right to left
margin-inline, padding-inline and
overscroll-behavior-inline are all logical, and the browser reverses the
scroll direction of an RTL container itself, so the row scrolls right-to-left and
starts at the right edge with no extra rules.
scroll-snap-align: start means the starting edge, not the left one, so
it also follows the direction. This is the reason it is start and not
left.
الأحد
الاثنين
الثلاثاء
الأربعاء
الخميس
<div dir="rtl" class="scroller">
<div class="card" style="inline-size:10rem"><div class="card-body"><h3 class="card-title">الأحد</h3></div></div>
<div class="card" style="inline-size:10rem"><div class="card-body"><h3 class="card-title">الاثنين</h3></div></div>
<div class="card" style="inline-size:10rem"><div class="card-body"><h3 class="card-title">الثلاثاء</h3></div></div>
<div class="card" style="inline-size:10rem"><div class="card-body"><h3 class="card-title">الأربعاء</h3></div></div>
<div class="card" style="inline-size:10rem"><div class="card-body"><h3 class="card-title">الخميس</h3></div></div>
</div>
<div dir="rtl" class="scroller">
<div class="card" style="inline-size:10rem"><div class="card-body"><h3 class="card-title">الأحد</h3></div></div>
<div class="card" style="inline-size:10rem"><div class="card-body"><h3 class="card-title">الاثنين</h3></div></div>
<div class="card" style="inline-size:10rem"><div class="card-body"><h3 class="card-title">الثلاثاء</h3></div></div>
<div class="card" style="inline-size:10rem"><div class="card-body"><h3 class="card-title">الأربعاء</h3></div></div>
<div class="card" style="inline-size:10rem"><div class="card-body"><h3 class="card-title">الخميس</h3></div></div>
</div>
Reduced motion
Nothing in .scroller animates. Scroll snapping is a positioning rule
rather than a transition, so it is not something
prefers-reduced-motion switches off — and it should not be, since a
reader who wants less motion still wants the row to line up.
If you add scroll-behavior: smooth or drive the scroller from
JavaScript, that is motion. Deck's carousel checks
matchMedia('(prefers-reduced-motion: reduce)') and uses
behavior: 'auto' when it matches; do the same.
Printing
Deck's print stylesheet has no rule for .scroller, which means a printed
scroller shows only what was on screen and silently drops the rest. That is a
gap, not a decision — the fix is
@media print { .scroller { display: block; overflow: visible; margin-inline: 0
} } so the items stack and all of them print. Recorded in
FINDINGS.md.
Overriding it
@layer app.components {
/* Give the scrollbar back */
.scroller { scrollbar-width: thin; }
.scroller::-webkit-scrollbar { display: block; block-size: 8px; }
/* Snap to the middle rather than the start */
.scroller > * { scroll-snap-align: center; }
/* No bleed, for a scroller that is not inside a .container */
.scroller-flush { margin-inline: 0; padding-inline: 0; }
}
scroll-snap-align: center is worth knowing about for a carousel of full
-width items, where centring reads better than aligning to the starting edge.
When not to use it
-
Not when the reader must see everything. Horizontal scrolling
hides content behind a gesture many readers never make. If all the items matter
equally, use
.gridand let them wrap onto several rows. -
Not for primary navigation. A scrolling row of nav links puts
destinations off-screen with no indication they exist. Use a
.barwith an overflow menu, or a.drawer. -
Not for a handful of items. Four chips that fit on one line gain
nothing from a scroller and lose the wrapping they would have had at 400% zoom. Use
.cluster. -
Not for a real carousel. If you need previous and next buttons,
dot indicators, autoplay or a slide-changed event, use
.carousel, which has all of them and handles the keyboard. A scroller is the layout underneath, not the component. -
Not outside a
.container, unless you override the bleed. The negative margin assumes the page gutter is its parent's padding, and without that it pulls the row out of alignment or gets clipped.