CSS Scroll-Snap Carousel
A dependency-free horizontal carousel powered entirely by CSS scroll-snap with mandatory snapping, snap-stop to prevent skipping, and native ::scroll-marker navigation dots that generate themselves from the slides. A progressive-enhancement @supports fallback swaps in anchor-link dots for browsers without scroll-marker-group, and a small JS layer only adds a live progress readout and previous/next controls.
MCP
Code
:root {
--bg: #0c0d10;
--surface: #15161b;
--surface-2: #1d1f27;
--ink: #e9eaf0;
--muted: #9a9cab;
--accent: #8b5cf6;
--accent-2: #22d3ee;
--ok: #34d399;
--warn: #fbbf24;
--danger: #f87171;
--line: rgba(255, 255, 255, 0.1);
--line-2: rgba(255, 255, 255, 0.18);
--r-sm: 8px;
--r-md: 12px;
--r-lg: 16px;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
-webkit-text-size-adjust: 100%;
}
body {
margin: 0;
min-height: 100vh;
background:
radial-gradient(120% 80% at 100% -10%, rgba(139, 92, 246, 0.14), transparent 60%),
radial-gradient(90% 70% at -10% 110%, rgba(34, 211, 238, 0.1), transparent 55%),
var(--bg);
color: var(--ink);
font-family: "Inter", system-ui, sans-serif;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
display: flex;
justify-content: center;
padding: clamp(16px, 4vw, 56px);
}
.wrap {
width: 100%;
max-width: 780px;
}
/* ---------- Header ---------- */
.head {
margin-bottom: 26px;
}
.eyebrow {
margin: 0 0 10px;
font-family: "JetBrains Mono", ui-monospace, monospace;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--accent-2);
}
.head h1 {
margin: 0 0 12px;
font-size: clamp(26px, 6vw, 40px);
font-weight: 700;
letter-spacing: -0.02em;
line-height: 1.1;
}
.lede {
margin: 0;
max-width: 62ch;
color: var(--muted);
font-size: 15px;
}
.lede strong {
color: var(--ink);
font-weight: 600;
}
.lede code {
font-family: "JetBrains Mono", ui-monospace, monospace;
font-size: 0.86em;
color: var(--accent);
background: rgba(139, 92, 246, 0.12);
border: 1px solid rgba(139, 92, 246, 0.24);
padding: 1px 6px;
border-radius: var(--r-sm);
white-space: nowrap;
}
/* ---------- Carousel shell ---------- */
.carousel {
position: relative;
background: linear-gradient(180deg, var(--surface), var(--surface-2));
border: 1px solid var(--line);
border-radius: var(--r-lg);
padding: 18px;
box-shadow: 0 24px 60px -30px rgba(0, 0, 0, 0.8);
}
/* The scroll container: this is where the snap magic lives. */
.track {
--gap: 16px;
list-style: none;
margin: 0;
padding: 0;
display: flex;
gap: var(--gap);
overflow-x: auto;
overscroll-behavior-x: contain;
scroll-behavior: smooth;
/* CSS Scroll Snap: mandatory snapping on the x axis */
scroll-snap-type: x mandatory;
scroll-padding: 0;
border-radius: var(--r-md);
/* soft edge fade so partially-visible neighbors feel intentional */
-webkit-mask-image: linear-gradient(90deg, transparent, #000 5%, #000 95%, transparent);
mask-image: linear-gradient(90deg, transparent, #000 5%, #000 95%, transparent);
scrollbar-width: none;
}
.track::-webkit-scrollbar {
display: none;
}
.track:focus-visible {
outline: 2px solid var(--accent-2);
outline-offset: 4px;
}
/* ---------- Slides ---------- */
.slide {
position: relative;
flex: 0 0 88%;
max-width: 560px;
min-height: 300px;
border-radius: var(--r-md);
overflow: hidden;
padding: 22px;
display: flex;
flex-direction: column;
justify-content: space-between;
color: #fff;
isolation: isolate;
/* Snap each slide to center and never let a flick skip past it */
scroll-snap-align: center;
scroll-snap-stop: always;
background:
radial-gradient(120% 90% at 85% -10%, hsl(var(--h) / 0.95), transparent 62%),
linear-gradient(150deg, hsl(var(--h) / 0.85), hsl(var(--h) / 0.35)),
#101116;
border: 1px solid rgba(255, 255, 255, 0.14);
}
.slide::after {
/* subtle noise/grid texture */
content: "";
position: absolute;
inset: 0;
z-index: -1;
background-image:
linear-gradient(rgba(255, 255, 255, 0.06) 1px, transparent 1px),
linear-gradient(90deg, rgba(255, 255, 255, 0.06) 1px, transparent 1px);
background-size: 28px 28px;
mask-image: radial-gradient(80% 80% at 20% 90%, #000, transparent 75%);
opacity: 0.5;
}
.slide__index {
font-family: "JetBrains Mono", ui-monospace, monospace;
font-size: 13px;
font-weight: 600;
letter-spacing: 0.1em;
color: rgba(255, 255, 255, 0.85);
align-self: flex-end;
padding: 4px 10px;
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 999px;
backdrop-filter: blur(4px);
}
.slide__tag {
display: inline-block;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: #fff;
background: rgba(0, 0, 0, 0.28);
border: 1px solid rgba(255, 255, 255, 0.28);
padding: 4px 10px;
border-radius: 999px;
margin-bottom: 10px;
}
.slide__title {
margin: 0 0 6px;
font-size: clamp(24px, 5vw, 34px);
font-weight: 700;
letter-spacing: -0.02em;
text-shadow: 0 2px 14px rgba(0, 0, 0, 0.35);
}
.slide__text {
margin: 0;
max-width: 34ch;
font-size: 14px;
color: rgba(255, 255, 255, 0.92);
text-shadow: 0 1px 8px rgba(0, 0, 0, 0.3);
}
/* =========================================================
NATIVE SCROLL MARKERS (CSS Carousel API)
Generates one dot per slide, no JS. Styled active state
via :target-current.
========================================================= */
.track {
scroll-marker-group: after;
}
.track::scroll-marker-group {
display: flex;
justify-content: center;
gap: 10px;
padding-top: 18px;
}
.slide::scroll-marker {
content: "";
width: 10px;
height: 10px;
border-radius: 999px;
background: var(--line-2);
border: 1px solid transparent;
cursor: pointer;
transition: background 0.2s ease, width 0.2s ease, border-color 0.2s ease;
}
.slide::scroll-marker:hover {
background: var(--muted);
}
.slide::scroll-marker:focus-visible {
outline: 2px solid var(--accent-2);
outline-offset: 3px;
}
/* The marker for the currently-snapped slide */
.slide::scroll-marker:target-current {
width: 26px;
background: linear-gradient(90deg, var(--accent), var(--accent-2));
border-color: transparent;
}
/* =========================================================
FALLBACK DOTS (anchor links) for browsers without markers.
Hidden entirely when ::scroll-marker is supported.
========================================================= */
.fallback-dots {
display: flex;
justify-content: center;
gap: 10px;
padding-top: 18px;
}
.fallback-dots a {
width: 10px;
height: 10px;
border-radius: 999px;
background: var(--line-2);
transition: background 0.2s ease, width 0.2s ease;
}
.fallback-dots a:hover {
background: var(--muted);
}
.fallback-dots a:focus-visible {
outline: 2px solid var(--accent-2);
outline-offset: 3px;
}
/* JS adds .is-active to the anchor matching the centered slide */
.fallback-dots a.is-active {
width: 26px;
background: linear-gradient(90deg, var(--accent), var(--accent-2));
}
@supports selector(::scroll-marker) {
/* Native markers exist -> hide the anchor fallback to avoid duplicate dots */
.fallback-dots {
display: none;
}
}
/* =========================================================
JS ENHANCEMENT: counter, prev/next, progress bar
========================================================= */
.controls {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
margin-top: 22px;
flex-wrap: wrap;
}
.controls__meter {
display: flex;
align-items: center;
gap: 12px;
}
.btn {
display: inline-grid;
place-items: center;
width: 40px;
height: 40px;
border-radius: var(--r-md);
background: var(--surface-2);
border: 1px solid var(--line-2);
color: var(--ink);
cursor: pointer;
transition: background 0.18s ease, border-color 0.18s ease, transform 0.1s ease;
}
.btn:hover {
background: #262835;
border-color: rgba(255, 255, 255, 0.28);
}
.btn:active {
transform: scale(0.94);
}
.btn:focus-visible {
outline: 2px solid var(--accent-2);
outline-offset: 2px;
}
.btn svg {
width: 20px;
height: 20px;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
.counter {
font-family: "JetBrains Mono", ui-monospace, monospace;
font-size: 15px;
font-weight: 600;
color: var(--ink);
min-width: 56px;
text-align: center;
}
.counter__sep {
color: var(--muted);
margin: 0 4px;
}
.counter #current {
color: var(--accent);
}
.progress {
flex: 1 1 200px;
height: 6px;
min-width: 140px;
background: var(--surface-2);
border: 1px solid var(--line);
border-radius: 999px;
overflow: hidden;
}
.progress__bar {
display: block;
height: 100%;
width: 16.66%;
border-radius: 999px;
background: linear-gradient(90deg, var(--accent), var(--accent-2));
transition: width 0.35s cubic-bezier(0.22, 1, 0.36, 1);
}
.hint {
margin: 20px 0 0;
font-family: "JetBrains Mono", ui-monospace, monospace;
font-size: 12.5px;
color: var(--muted);
display: flex;
align-items: center;
gap: 8px;
}
.hint::before {
content: "";
width: 8px;
height: 8px;
border-radius: 999px;
background: var(--warn);
box-shadow: 0 0 0 4px rgba(251, 191, 36, 0.16);
}
.hint.is-native::before {
background: var(--ok);
box-shadow: 0 0 0 4px rgba(52, 211, 153, 0.16);
}
/* ---------- Responsive ---------- */
@media (max-width: 520px) {
.slide {
flex-basis: 84%;
min-height: 260px;
padding: 18px;
}
.controls {
justify-content: center;
}
.progress {
order: 3;
flex-basis: 100%;
}
}
/* ---------- Reduced motion ---------- */
@media (prefers-reduced-motion: reduce) {
.track {
scroll-behavior: auto;
}
.slide::scroll-marker,
.fallback-dots a,
.progress__bar,
.btn {
transition: none;
}
}// Progressive enhancement only. The carousel's snapping and marker dots
// work with this whole file deleted. Here we add a live counter, a progress
// bar, prev/next buttons, and fallback-dot highlighting.
(function () {
"use strict";
const track = document.getElementById("track");
const slides = Array.prototype.slice.call(track.querySelectorAll(".slide"));
const total = slides.length;
const currentEl = document.getElementById("current");
const totalEl = document.getElementById("total");
const progressBar = document.getElementById("progressBar");
const progress = progressBar.parentElement;
const fallbackDots = Array.prototype.slice.call(
document.querySelectorAll(".fallback-dots a")
);
const supportHint = document.getElementById("supportHint");
totalEl.textContent = String(total);
// ---- Feature-detect native ::scroll-marker so we can tell the user ----
const hasScrollMarker =
typeof CSS !== "undefined" &&
CSS.supports &&
CSS.supports("selector(::scroll-marker)");
supportHint.classList.toggle("is-native", hasScrollMarker);
supportHint.textContent = hasScrollMarker
? "Native ::scroll-marker supported — the dots are generated by CSS."
: "::scroll-marker not supported here — showing the anchor-link fallback dots.";
// ---- Track the centered slide with IntersectionObserver ----
let activeIndex = 0;
function setActive(index) {
if (index === activeIndex) return;
activeIndex = index;
currentEl.textContent = String(index + 1);
progressBar.style.width = ((index + 1) / total) * 100 + "%";
progress.setAttribute("aria-valuenow", String(index + 1));
fallbackDots.forEach(function (dot, i) {
dot.classList.toggle("is-active", i === index);
});
}
const io = new IntersectionObserver(
function (entries) {
// The most-visible intersecting slide wins.
let best = null;
entries.forEach(function (entry) {
if (
entry.isIntersecting &&
(!best || entry.intersectionRatio > best.intersectionRatio)
) {
best = entry;
}
});
if (best) {
setActive(slides.indexOf(best.target));
}
},
{ root: track, threshold: [0.5, 0.75, 1] }
);
slides.forEach(function (slide) {
io.observe(slide);
});
// ---- Prev / next buttons ----
document.querySelectorAll(".btn[data-dir]").forEach(function (btn) {
btn.addEventListener("click", function () {
const dir = parseInt(btn.getAttribute("data-dir"), 10);
const next = Math.min(total - 1, Math.max(0, activeIndex + dir));
slides[next].scrollIntoView({
behavior: matchMedia("(prefers-reduced-motion: reduce)").matches
? "auto"
: "smooth",
inline: "center",
block: "nearest",
});
});
});
// ---- Arrow-key support when the track is focused ----
track.addEventListener("keydown", function (e) {
if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
e.preventDefault();
const dir = e.key === "ArrowRight" ? 1 : -1;
const next = Math.min(total - 1, Math.max(0, activeIndex + dir));
slides[next].scrollIntoView({
behavior: "smooth",
inline: "center",
block: "nearest",
});
});
// Initialise UI to the first slide.
progressBar.style.width = 100 / total + "%";
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>CSS Scroll-Snap Carousel</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@500;600&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<main class="wrap">
<header class="head">
<p class="eyebrow">CSS Scroll Snap · ::scroll-marker</p>
<h1>Scroll-Snap Carousel</h1>
<p class="lede">
A horizontal carousel with <strong>zero JavaScript</strong> at its core. Flick, drag, or
use the arrow keys — <code>scroll-snap-type: x mandatory</code> and
<code>scroll-snap-stop: always</code> guarantee you always land on exactly one slide.
The dots below are generated by the browser with <code>::scroll-marker</code>.
</p>
</header>
<!-- The carousel. Core behavior (snapping + marker dots) is pure CSS. -->
<section class="carousel" aria-roledescription="carousel" aria-label="Featured city guides">
<ul class="track" id="track" tabindex="0" aria-label="Slides, use arrow keys to scroll">
<li class="slide" id="slide-1" style="--h: 232 60% 52%" aria-label="Slide 1 of 6: Lisbon">
<span class="slide__index">01</span>
<div class="slide__body">
<span class="slide__tag">Coastal</span>
<h2 class="slide__title">Lisbon</h2>
<p class="slide__text">Tiled facades, tram 28, and pastel de nata at golden hour.</p>
</div>
</li>
<li class="slide" id="slide-2" style="--h: 276 62% 55%" aria-label="Slide 2 of 6: Kyoto">
<span class="slide__index">02</span>
<div class="slide__body">
<span class="slide__tag">Temples</span>
<h2 class="slide__title">Kyoto</h2>
<p class="slide__text">Bamboo groves, quiet shrines, and slow tea in Higashiyama.</p>
</div>
</li>
<li class="slide" id="slide-3" style="--h: 190 68% 48%" aria-label="Slide 3 of 6: Reykjavik">
<span class="slide__index">03</span>
<div class="slide__body">
<span class="slide__tag">Aurora</span>
<h2 class="slide__title">Reykjavík</h2>
<p class="slide__text">Geothermal pools and green skies over black-sand coasts.</p>
</div>
</li>
<li class="slide" id="slide-4" style="--h: 16 78% 55%" aria-label="Slide 4 of 6: Marrakesh">
<span class="slide__index">04</span>
<div class="slide__body">
<span class="slide__tag">Souks</span>
<h2 class="slide__title">Marrakesh</h2>
<p class="slide__text">Spice markets, riads, and mint tea in the medina.</p>
</div>
</li>
<li class="slide" id="slide-5" style="--h: 148 55% 46%" aria-label="Slide 5 of 6: Queenstown">
<span class="slide__index">05</span>
<div class="slide__body">
<span class="slide__tag">Alpine</span>
<h2 class="slide__title">Queenstown</h2>
<p class="slide__text">Fjords, ridgelines, and lake air on the edge of the Alps.</p>
</div>
</li>
<li class="slide" id="slide-6" style="--h: 320 60% 55%" aria-label="Slide 6 of 6: Cartagena">
<span class="slide__index">06</span>
<div class="slide__body">
<span class="slide__tag">Caribbean</span>
<h2 class="slide__title">Cartagena</h2>
<p class="slide__text">Walled-city color, salsa nights, and sea-breeze balconies.</p>
</div>
</li>
</ul>
<!--
Fallback navigation dots for browsers WITHOUT ::scroll-marker.
A CSS @supports query hides this whole block when scroll markers are available,
so you never see duplicated dots.
-->
<nav class="fallback-dots" aria-label="Choose a slide (fallback)">
<a href="#slide-1" aria-label="Go to slide 1"></a>
<a href="#slide-2" aria-label="Go to slide 2"></a>
<a href="#slide-3" aria-label="Go to slide 3"></a>
<a href="#slide-4" aria-label="Go to slide 4"></a>
<a href="#slide-5" aria-label="Go to slide 5"></a>
<a href="#slide-6" aria-label="Go to slide 6"></a>
</nav>
</section>
<!-- Optional JS enhancement layer: counter, progress, prev/next. Carousel works without it. -->
<footer class="controls" aria-label="Carousel controls">
<div class="controls__meter">
<button class="btn" type="button" data-dir="-1" aria-label="Previous slide">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M15 5l-7 7 7 7" /></svg>
</button>
<div class="counter" aria-live="polite">
<span id="current">1</span><span class="counter__sep">/</span><span id="total">6</span>
</div>
<button class="btn" type="button" data-dir="1" aria-label="Next slide">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M9 5l7 7-7 7" /></svg>
</button>
</div>
<div class="progress" role="progressbar" aria-label="Scroll position"
aria-valuemin="1" aria-valuemax="6" aria-valuenow="1">
<span class="progress__bar" id="progressBar"></span>
</div>
</footer>
<p class="hint" id="supportHint">Checking scroll-marker support…</p>
</main>
<script src="script.js"></script>
</body>
</html>CSS Scroll-Snap Carousel
A horizontal carousel whose core behavior needs zero JavaScript. The track is a
scroll-snap-type: x mandatory scroll container and each slide declares
scroll-snap-align: center plus scroll-snap-stop: always, so a single flick always lands
on exactly one slide instead of flying past several. The navigation dots are generated by the
browser itself using the CSS Carousel primitives ::scroll-marker and scroll-marker-group —
clicking a marker smooth-scrolls to its slide and the active marker is styled via
:target-current, all without a line of script.
Because ::scroll-marker is still rolling out, an @supports not (…) block feature-detects it
and swaps in a row of plain anchor-link dots (<a href="#slide-3">) that reach the same slides
through fragment navigation and scroll-behavior: smooth. Either way the carousel is keyboard
operable, focus-visible, and reduced-motion aware.
The optional script.js layer is pure enhancement: an IntersectionObserver tracks which slide
is centered to drive a live “3 / 6” counter and a progress bar, and previous/next buttons call
scrollIntoView. Remove the script entirely and the carousel — snapping, dots, and all — keeps
working. It demonstrates the modern CSS Scroll Snap and CSS Carousel (scroll markers) web
platform APIs.