CSS Scroll Markers Generate Your Carousel Dots With Zero JavaScript
For a decade, carousel pagination meant the same ritual: a scroll listener to track position, a ResizeObserver to count slides, a click handler to scroll to an offset, and a class toggle to light up the current dot. None of it is hard. All of it is boilerplate you rebuild in every project.
CSS can now generate the dots, wire up click-to-scroll, and track the active slide by itself. The feature is called scroll markers, and it collapses a couple hundred lines of component code into a scroll container plus a handful of declarations. Arrows are one more pseudo-element away. This is how it works and where you still keep a JavaScript backup.
The old way
Most carousels run the same script on every page load. They count the children, render one dot per child, listen for scroll or an IntersectionObserver to learn which child is centered, and swap an active class. When the breakpoint changes and you show three slides instead of one, the dot count goes stale until the observer fires again. The whole machinery exists to answer one question: which item is in view, and how do I jump to it?
What scroll markers generate
Give the scroll container the declaration scroll-marker-group: after and the browser appends a marker for every direct child. Each marker is a ::scroll-marker box, grouped inside a ::scroll-marker-group wrapper. Clicking a marker scrolls the container to that child. That is the whole feature: the dots, their positioning, and click-to-scroll, with no extra element in the DOM and no script running.
.carousel {
display: flex;
gap: 1rem;
overflow-x: auto;
scroll-snap-type: x mandatory;
scroll-marker-group: after;
}
.card {
flex: 0 0 280px;
scroll-snap-align: start;
}
.carousel::scroll-marker {
content: "";
width: 10px;
height: 10px;
border-radius: 50%;
background: #cbd5e1;
cursor: pointer;
}
.carousel::scroll-marker::scroll-snapped {
background: #0f172a;
}The snap points that make the dots correct come from scroll-snap-align on the cards. Without them there is nothing to snap to, and the active state has no anchor. Markers and snap points are a pair, not two independent features.
The active dot for free
The part that usually needs an IntersectionObserver is now a pseudo-element. ::scroll-snapped matches a marker only while its target child is snapped into view, so the current dot lights up on its own as the user scrolls. There is no position math, no observer to create, and nothing to clean up on unmount. You style the resting dot and the active dot, and the browser switches between them.
Arrows are a pseudo-element away
Scroll buttons are the newer half of this feature set and they are still rolling out, so treat them as a progressive enhancement. ::scroll-button(start) and ::scroll-button(end) render generated previous and next buttons; clicking one scrolls by a page. Where arrows used to be a ResizeObserver plus a scrollIntoView call, you now write CSS and let the engine handle the scrolling.
.carousel {
position: relative;
}
.carousel::scroll-button(start) {
position: absolute;
left: 0.5rem;
top: 50%;
}
.carousel::scroll-button(end) {
position: absolute;
right: 0.5rem;
top: 50%;
}Browser reality
Chromium and Safari have shipped scroll markers, while Firefox is still catching up in 2026. You cannot treat the feature as a hard dependency yet. The pattern that works: ship the native markers and arrows, and let the container degrade to a plain horizontally scrollable row wherever the feature is missing. If the design requires dots in every browser, keep a small IntersectionObserver fallback behind a feature query.
Gotchas
- Markers map to the direct children of the scroll container. If your cards live inside a wrapper element, the markers do not match. Flatten the DOM, or move the scroll onto the wrapper.
- The generated markers and buttons are real focusable controls, which helps keyboard users. A row of dot markers with no accessible name does not, though. Give each marker a label, or keep an off-screen list for assistive technology.
- Keep the marker content a shape, not text. The generated box is limited in what it renders, and a dot or a bar is all you usually want.
- Scroll buttons still lag the markers in support. Ship them opt-in, and do not build the core layout on top of them.
When to stay with JavaScript
If you need drag-to-scroll, fully custom keyboard navigation, markers on nested grandchildren, or a dot count that changes with the breakpoint and cannot be flattened, JavaScript remains the right tool. This feature is not a replacement for every carousel library. It is the standard pagination and arrow case, done without the plumbing.
The bigger point is the direction the platform is moving: it absorbs the small amount of glue code that used to make a component feel finished. Scroll markers remove an entire category of scroll-listener work. Ship the native version first, fall back only where you must, and stop rebuilding the dots.
Comments