Skip to content
AtomicReps

Two arrows and a row of dots

A lesson from Modern CSS: What The Browser Took Back. Play it above, or read it through below.

  • 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.

    Zero. All four generate in Chromium; Firefox and Safari have none of them, and neither has taken a position on the proposal, so there is no version to wait for.

    That is a verdict about the chrome, which is the smaller half. Strip the arrows and the dots off 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 your users actually feel: the arrows are two buttons and a scrollBy call, and the dots are a list of links. The library you are paying for is mostly a scroll-position state machine, and the browser has had one all along.

    So the shape of this lesson is decided by the support table rather than by taste. Learn the snapping, ship the snapping, write the arrows and the dots yourself, and read the last two screens for what a one-engine feature is actually telling you.

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

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

    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 the scroll container it is a direct child of has opted in. Which half is doing which job.

    The container declares the axis and how hard the engine is allowed to insist. The child declares where its own edge lands. Both are required, and the initial value of scroll-snap-align is none, so a rail full of cards with no alignment declared is a rail with no snap positions and no snapping. Three more things fall out of the grammar and all three are silent.

    The axis keyword is not optional: scroll-snap-type: mandatory with no axis is invalid, and on the pinned engines the declaration is dropped, getComputedStyle(rail).scrollSnapType reads "none", and a scroll to 340 stays at 340. The strictness keyword is optional and its default is proximity, which is why scroll-snap-type: x and scroll-snap-type: x proximity both serialise back as "x" in all three engines.

    And the whole mechanism needs a scroll container: set overflow-x: visible and the computed value still reads "x mandatory" while nothing scrolls and nothing snaps.

    Every scroll-snap property here ships in every browser. Put the axis and the strictness on the scroller, put the alignment on the direct children of that scroller, and read the computed value of scroll-snap-type when snapping does nothing, because the two ways this fails silently both show up there.

  • mandatory is a promise 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. Read the strictness keyword as a sentence about the container and the alignment as a sentence about a card, and the whole surprise disappears: you did not switch off snapping for one tile, you deleted the only offsets that would have shown it.

    The relaxation is proximity, and it is the default for a reason. Under proximity the engine attracts the resting offset to a nearby snap position and otherwise leaves it alone, so the same rail with the same unaligned tile settles wherever you stopped: measured on the pinned engines, requests for 880, 900 and 920 settle at 880, 900 and 920.

    That is the fix for the see-all tile, and it is also the fix for the failure mode that reaches you as an accessibility report. A snap child taller or wider than the container's viewport under mandatory has the same shape of problem, since the engine forces the resting offset to one edge of the card and the middle becomes a place nobody can stop.

    Choose the strictness against the content, not against the feel. mandatory when every child is one screen or smaller and every child is a stop. proximity the moment either of those stops being true, and in particular the moment a card can grow: a rail whose cards are one line of text today and three lines after a translation pass is a rail that has already changed strictness and does not know it.

  • 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 occupies space. Neither one shifts a pixel of what is drawn, and both change exactly where scrolling comes to rest.

    Measured on the pinned engines, with the same rail and 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 ends, and which one you want is a question about scope rather than about arithmetic. scroll-padding is the container saying "this strip of me is spoken for", which is the right answer for a pinned chip, a sticky header or a gradient mask, because it applies to every card including the ones added next quarter. scroll-margin is one card saying "give me room", which is the right answer for the one card that is different.

    Reach for scroll-padding on the container by default, and treat anything pinned over a scroll container as owing that container a matching scroll-padding. The failure is silent in both directions: forget it and cards rest underneath the pinned chip, and set ordinary padding instead and the layout moves, the resting offset moves with it, and the card lands exactly 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. That single sentence is what the last three screens have been circling, and it is worth holding in exactly this form, because everything else about scroll snap is a consequence of it.

    Every consequence is a thing you would otherwise have written code for. Content changes and the container re-snaps to the element it had selected, which is the measurement you just watched and is layout-shift immunity you get for declaring two properties.

    A scroll ends and the container selects the nearest snap position in each snapped axis: measured on the pinned engines, a request for 1450 on a rail of 300-pixel cards settles at 1500 and a request for 420 settles at 300, in all three.

    Anything that scrolls the container goes through the same pipeline, including the scroll the browser performs when focus moves to something off screen: measured on the pinned engines, 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, in all three, where the same move without snapping stops at three different arbitrary offsets.

    The one part of the selection you are allowed to constrain is whether a fast gesture may pass over targets on its way. scroll-snap-stop: always on a child forbids that, and normal, the initial value, permits it. Measure this one and you get nothing: on all three pinned engines a programmatic scrollTo that crosses four targets lands on the requested one with always and with normal alike, because there is no fling to pass over.

    That is the honest shape of the property, and it is worth knowing before you write a test for it: scroll-snap-stop is a rule about a gesture, so a headless browser, a unit test and your own keyboard will all report that it does nothing.

    Stop thinking of the rail's position as a number you manage. Declare the snap targets, declare whether a fling may skip them, and read scrollLeft as a derived value, because the moment you write to it you are arguing with a state machine that will re-snap on the next frame.

  • 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.

    Four declarations and no elements. scroll-marker-group: after on the rail asks for 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 it is in the DOM. What can you do with it.

    Measured in chromium 151.0.7922.34 only, and stated as a chromium measurement rather than as a behaviour: the stylesheet in the figure adds 48 pixels of generated boxes below the rail, 24 for the marker strip and 24 for the buttons, against an identical rail without those declarations. Clicking where the generated left button sits moves scrollLeft from 600 to 300, and clicking the right one moves it from 600 to 900, so the buttons page by one card without a listener anywhere.

    Clicking the fourth, fifth and sixth generated markers moves it to 900, 1200 and 1500. The whole control strip is four declarations and no elements, and it is also, today, a screenshot.

  • 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 version of that fact worth carrying out of this lesson is not the count, it is why a working, demonstrable, well-liked feature has been sitting at one engine while both of the others decline to say anything at all.

    Because what the markup MEANS is still being designed. Every document a senior would read describes the generated marker strip as a tab list with arrow-key navigation, and the current specification text splits that into two modes, a link-list mode and a tab-list mode, with the link list as the default.

    That split is itself an open working-group question whose proposed vocabulary has already changed once mid-discussion, and it is not a paper distinction: in chromium 151.0.7922.34 today, scroll-marker-group: after computes to "after links", which is a value the local MDN clone does not document. Underneath it sits a harder problem.

    A marker generated from a card has no text of its own, so there is no mechanism for giving it an accessible name, and MDN's own carousel guide states that the pattern produces a WCAG failure. The proposal to fix that is open and has two competing unshipped answers. Safari's public position on the whole feature is not a rejection and not a timeline: it is an open request annotated with unresolved accessibility and internationalisation concerns.

    So read a one-engine feature by asking what the second engine is waiting for. Waiting for engineering time is a rollout and you can plan around it. Waiting for the working group to decide what the thing means is not a rollout, and anything you write against today's behaviour is written against a draft: the roles, the keyboard model and the naming mechanism can all still move.

    Ship the snapping, which is in every engine and is the half your users feel. Write the two buttons and the row of links yourself, where you own the accessible names. Come back to the pseudo-elements when the second engine says something.

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