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.

Drag or shift-scroll. Notice the item that is half-visible at the end.

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.

.stack-6 on a scroller — the step classes only set --gap

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.

Filters that stay on one line
All Overdue This week Unassigned Archived Drafts Shared with me
<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.

Classes, generated from the stylesheet source
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

Design tokens this component reads
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. .scroller sets no tabindex, 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, add tabindex="0" and a role="region" with an aria-label. This is the same defect as .table-wrap, and it is recorded in FINDINGS.md.
  • Do not add aria-hidden to 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 — but scroll-behavior: smooth, if you add it, is affected, and the global reset turns it off.
  • Proximity snapping is the accessible choice. mandatory can 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.

Starts at the right and scrolls the other way

الأحد

الاثنين

الثلاثاء

الأربعاء

الخميس

<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 .grid and 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 .bar with 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.