Skip to content
AtomicReps

Scroll-snap carousels, and the CSS arrows and dots only Chromium ships

A lesson from Modern CSS: Features That Replace JavaScript. Play it above, or read it through below.

Level I · CSS that replaces JavaScript
  • Zero of the four ship. The scrolling underneath them ships everywhere.

    The common read: the CSS carousel is a Chromium demo, so there is nothing here I can put in a release this quarter.

    The answer is zero. All four generate in Chromium; Firefox and WebKit have none of them, and neither has taken a position on the proposal, so there is no version to wait for. That verdict is about the controls, which are the smaller half of a carousel.

    Remove the arrows and the dots from any carousel you have shipped and what remains is a row that scrolls sideways and comes to rest on a card rather than between two of them. That behaviour is scroll-snap-type and scroll-snap-align, it has shipped in all three engines for years, and it is the part users notice. The arrows are two buttons and a scrollBy call, and the dots are a list of links. Learn the snapping, ship it, and write the arrows and the dots yourself.

  • The container declares the axis. The child declares the edge.

    The common read: scroll-snap-type turns snapping on, so a container with that declaration snaps.

    This rail needs two declarations, and neither of them does what its name suggests on its own. scroll-snap-type: x mandatory; on the rail creates no snap positions at all, and scroll-snap-align: start; on a card does nothing until its nearest ancestor scroll container has opted in. Which declaration does which job?

    The container declares the axis and how strictly the engine enforces snapping. The child declares which of its edges aligns with the container. Both are required: the initial value of scroll-snap-align is none, so a rail of cards with no alignment declared has no snap positions. The strictness keyword is optional and defaults to proximity, so scroll-snap-type: x and scroll-snap-type: x proximity both serialise as "x" in all three engines. Every scroll-snap property here ships in every browser.

    The axis keyword is required, and a mistake here produces no error: scroll-snap-type: mandatory is invalid, all three engines drop it, getComputedStyle(rail).scrollSnapType reads "none", and a scroll to 340 stays at 340. Snapping also needs a scroll container. When snapping does nothing, read two computed values on the scroller: scroll-snap-type must not read "none", and overflow-x must read "auto", "scroll" or "hidden". With "visible" or "clip", scroll-snap-type still reads "x mandatory" and nothing scrolls or snaps.

  • mandatory is a rule about where scrolling may stop, not about the cards.

    The common read: A child with no scroll-snap-align behaves like ordinary content, so leaving it off a tile that should not be a stop costs nothing.

    mandatory is a constraint on the container's resting offset, so a child that contributes no snap position is a child the container may never come to rest on. You did not turn off snapping for one tile; you removed the only resting offsets that would have shown it.

    proximity, the default, moves the resting offset to a snap position only when one is nearby and otherwise leaves it where the scroll ended. The same rail with the same unaligned tile then settles wherever you stopped: in Chromium, Firefox and WebKit, requests for 880, 900 and 920 settle at 880, 900 and 920. proximity is also the safer value when a card can be larger than the rail.

    The specification lets a mandatory rail rest anywhere inside a card larger than the rail, and Chromium does: a 900-pixel card in a 300-pixel rail rests at 450, 600 and 750 when asked to. MDN still warns against mandatory for overflowing content, so do not assume every engine does the same. Use mandatory only when every child is one screen or smaller and every child is a stop. A card with one line of text today may have three after translation, and then mandatory is the wrong value.

  • A snap position is an alignment between two boxes, and you can move either one.

    The common read: The pinned chip covers the card, so padding on the rail is what moves the snapped card out from under it.

    A snap position is an alignment between the card's snap area and the container's snapport, so the two properties that move it are the two that resize those boxes without touching the layout: scroll-padding on the container and scroll-margin on the child. Neither one takes up space or moves anything that is drawn; both change only where scrolling comes to rest.

    Measured in Chromium, Firefox and WebKit, with the same request to scroll to the third card: bare, the rail settles at 600 and the card's leading edge is 0 pixels from the rail's; with scroll-padding-left: 96px on the rail it settles at 504 and the card sits 96 pixels in; with scroll-margin-left: 96px on the card, the same 504 and the same 96. The two properties reach the same offset from opposite sides of the alignment.

    Choose by scope. scroll-padding reserves a strip of the snapport for every card, including cards added later, which suits a pinned chip, a sticky header or a gradient mask. scroll-margin adds space around one card's snap area, for the one card that is different. Give a scroll container a matching scroll-padding whenever something is pinned over it. Neither mistake produces an error: without it, cards rest under the chip; with ordinary padding, the layout and the resting offset move together and the card rests where it did before.

  • The rail's state is which card, not which pixel.

    The common read: The scroll offset is a number the browser holds until something changes it, so content growing to the left of the card you are on pushes that card along the rail.

    A snapping container's state is the snap target it has selected, and the scroll offset is derived from that target rather than the other way round. When content changes, the container re-snaps to the element it had selected, so that element keeps its position on screen and you write no code to undo the layout shift. Read scrollLeft as a derived value: on a mandatory rail the engine moves any value you write to it to a snap position.

    When a scroll ends, the kind of scroll decides which snap position is selected. A scroll to a position, such as scrollTo, selects the nearest one: on a rail of 300-pixel cards, a request for 1450 settles at 1500 and a request for 420 at 300, in all three engines. A scroll in a direction, such as an arrow key or scrollBy, selects the nearest one in that direction: in Chromium, scrollBy({ left: 40 }) from 600 settles at 900.

    A scroll the browser makes for focus follows the same rule. In Chromium, Firefox and WebKit, moving focus into a button in the fourth card takes the rail from 0 to 900 and puts that card's leading edge at the rail's; without snapping, the same move stops at three different arbitrary offsets.

    The one choice you can constrain is whether a directional scroll may pass over a target: scroll-snap-stop: always on a child forbids it, and normal, the initial value, permits it. In Chromium, scrollBy({ left: 1200 }) from 0 settles at 1200 with normal and at 300 with always. scrollTo shows no difference, because it jumps to a position and passes over nothing, so test the property with scrollBy or the Page Down key.

  • Four declarations and no elements produce the arrows and the dots.

    The common read: the generated carousel controls are a proposal, so there is nothing concrete to look at yet.

    The arrows and the dots come from four declarations, with no elements added. scroll-marker-group: after on the rail creates a strip of markers after it, ::scroll-marker on the cards fills that strip with one marker per card, :target-current selects the marker for the card the engine has selected, and ::scroll-button(left) and ::scroll-button(right) generate the arrows. None of these controls is in the DOM. What can you do with them?

    Measured in Chromium 151.0.7922.34 only: the figure's stylesheet adds 48 pixels of generated boxes below the rail, 24 for the marker strip and 24 for the buttons, compared with the same rail without those declarations. Clicking the generated left button moves scrollLeft from 600 to 300 and the right one from 600 to 900, one card per click with no event listener. Clicking the fourth, fifth and sixth markers moves scrollLeft to 900, 1200 and 1500. Today only Chromium users can use any of it.

  • Zero is the answer, and the reason is that the meaning is not settled.

    The common read: one engine has it and the others are behind, so this is a normal rollout and my job is to wait.

    So the count is zero. The useful part is the reason: why a feature that works in a demo has shipped in one engine while neither of the other two has stated a position on it.

    The meaning of the generated markup is still being designed. Widely read documents describe the marker strip as a tab list; the current specification splits it into a link-list mode, the default, and a tab-list mode. That split is an open working-group question whose vocabulary has changed once, and Chromium 151.0.7922.34 already computes scroll-marker-group: after as "after links", a value MDN did not document at the time of writing. Generated markers also have no accessible-name mechanism, which MDN's carousel guide calls a WCAG failure.

    The naming fix has two competing unshipped proposals, and WebKit's open position request records accessibility and internationalisation concerns, not a rejection or a timeline. When a feature ships in one engine, ask why: engineering time is a rollout you can plan around, and an undecided meaning is a draft whose roles, keyboard model and naming can still change. Ship the snapping, write the buttons and links yourself with your own accessible names, and return to the pseudo-elements when a second engine states a position.

    Someone tells you the CSS carousel has shipped, and the accurate one-line version is that