Skip to content

PRIMITIVE

Rail

A row of equal things that scrolls sideways when they stop fitting — and shows exactly one of them once the box gets narrow.

Five cards in a row

Drag it, or tab to the track and use the arrow keys. Then narrow your window past 480px and watch it become one card and a sliver.

01

Four across while they fit.

02

One at a time once they do not.

03

Snap, so a card always lands square.

04

And a sliver of the next, so you know.

05

Tab to the track and the arrow keys work.

HTML
<div class="rail" style="width:100%">
  <div class="rail-track hairline-grid scrollbar" tabindex="0">
    <div class="hairline-cell">
      <span class="section-head-kicker">01</span>
      <p class="placeholder-caption">Four across while they fit.</p>
    </div>
    <div class="hairline-cell">
      <span class="section-head-kicker">02</span>
      <p class="placeholder-caption">One at a time once they do not.</p>
    </div>
    <div class="hairline-cell">
      <span class="section-head-kicker">03</span>
      <p class="placeholder-caption">Snap, so a card always lands square.</p>
    </div>
    <div class="hairline-cell">
      <span class="section-head-kicker">04</span>
      <p class="placeholder-caption">And a sliver of the next, so you know.</p>
    </div>
    <div class="hairline-cell">
      <span class="section-head-kicker">05</span>
      <p class="placeholder-caption">Tab to the track and the arrow keys work.</p>
    </div>
  </div>
</div>

Reference

Read from primitives/rail.css when this page was built, so it cannot fall behind the stylesheet.

CLASSES.rail.rail-track
SETTINGSSet the first on one element; a theme sets the second for all of them.--cell — falls back to --ds-cell-min
READS--ds-focus-ring-inset--ds-focus-width--ds-rail-peek--ds-text-h3-min

Notes

WHY IT IS TWO ELEMENTS

The threshold asks how wide the rail is, and in CSS an element cannot query itself — put container-type on the scroller and it becomes a container for its children while answering nothing about its own width. So .rail is the container and .rail-track is the thing that scrolls. The wrapper costs one div, and it is also the right home for an edge fade or a pair of arrows later, neither of which belongs on a scroll container.

THE ONE THRESHOLD, AND WHY NO FORMULA REPLACES IT

Fair question — this library has no breakpoints, so why a number here? Because a card wants to be 87% of the box on a phone and 24% of it on a desktop, and one track floor cannot say both. We tried: minmax(min(100% - peek, 320px), 1fr) gives exactly one per view at 390px and makes a 1440px desktop scroll, because four 320px floors need 1280px and the shell has 1119. Drop the floor to fit the desktop and the phone shows 1.6 cards. That is two states, not a range.

So the number is derived rather than chosen: the rail is one-per-view exactly while the box cannot hold two cards at the library’s own track floor. --ds-cell-min is 240px, so the switch is at 480px — 30rem. Measured against the prototype, a 479px box fits precisely 2.00 cards, so it lands on the boundary rather than near it.

WHAT ONE-PER-VIEW DOES BESIDES RESIZING

A card that fills the screen is not a big card, and the type scale cannot know that on its own. --ds-text-h3 is 7cqi of the card, so widening the card widened the heading — measured on the first rail we shipped, the title was 17px on a 1440px desktop where the card is 280px and 23px on a 480px phone where the card is 397px. Biggest on the smallest screen, which is backwards. In one-per-view the heading takes the scale’s own floor: the size it has on an ordinary card, because the card is doing an ordinary card’s job.

It also sets --rail-brief: none, which a component can read to drop its secondary text. That is a handshake rather than a rule here, and it has to be: a rail is a primitive, so a rule of its own targeting a section or a component would lose to that layer whatever its specificity. Inheritance crosses layers, and it keeps this threshold spelled in exactly one file — interest-card reads it with a fallback and a card outside a rail never notices.

A STRIP YOU CAN SWIPE IS ONE A KEYBOARD CANNOT REACH

Unless every item in it is focusable, or the track carries tabindex="0". The first rail this library drew held four cards, two of them links and two plain boxes — so a keyboard reached two and the rest scrolled out of reach with nothing to tab to. Add the attribute and the track takes focus, shows the ring, and scrolls with the arrow keys. It is the reason there is a :focus-visible on a thing that otherwise never takes focus.

Two more that are not decoration. overscroll-behavior-inline: contain stops a swipe that reaches the end of the rail from carrying on into the browser’s back gesture — without it, flicking through cards navigates away from the page. And scroll-snap-stop: always stops a fast fling skipping past its first entries. A rail you can throw past its contents is a carousel, which is the thing this is deliberately not.