Web Animations Easy
Horizontal Scroll Gallery
A horizontal scroll-snap gallery that can be driven by wheel input or buttons.
Open in Lab
MCP
html css javascript react
Targets: TS JS HTML React
Code
/* Horizontal Scroll Gallery */
:root {
color-scheme: dark;
--bg: #0b1020;
--panel: #121a2d;
--line: #263555;
--line-2: #3d4a70;
--text: #eef2ff;
--muted: #a8b5d1;
--accent: #78a9ff;
}
*, *::before, *::after { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
background: radial-gradient(1100px 620px at 50% -12%, #18213c, var(--bg));
color: var(--text);
font: 15px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif;
display: grid;
place-items: center;
padding: clamp(1rem, 4vw, 3rem);
}
button, input { font: inherit; }
.demo { width: 100%; }
.gallery {
width: min(920px, 100%);
margin: 0 auto;
background: var(--panel);
border: 1px solid var(--line);
border-radius: 22px;
padding: 1.25rem 0 1.1rem;
box-shadow: 0 20px 70px #0006;
}
.gallery__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0 1.35rem 1rem;
}
.gallery__head h1 {
margin: 0;
font-size: 1.1rem;
letter-spacing: -0.01em;
}
.gallery__nav { display: flex; gap: .5rem; }
.navbtn {
width: 36px;
height: 36px;
border-radius: 11px;
border: 1px solid var(--line);
background: #18233b;
color: var(--text);
font-size: 1.1rem;
line-height: 1;
cursor: pointer;
transition: background .15s, transform .15s, opacity .15s;
}
.navbtn:hover:not(:disabled) { background: #22304f; }
.navbtn:active:not(:disabled) { transform: scale(.93); }
.navbtn:disabled { opacity: .32; cursor: default; }
.navbtn:focus-visible,
.rail:focus-visible,
.dots button:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
/* ---------- the scroll rail ---------- */
.rail {
display: flex;
gap: 1rem;
overflow-x: auto;
overscroll-behavior-x: contain;
scroll-snap-type: x mandatory;
scroll-padding-inline: 1.35rem;
padding: .25rem 1.35rem 1.1rem;
scrollbar-width: none;
}
.rail::-webkit-scrollbar { display: none; }
.card {
flex: 0 0 clamp(200px, 34%, 262px);
scroll-snap-align: start;
background: #18233b;
border: 1px solid var(--line);
border-radius: 16px;
padding: .6rem .6rem 1rem;
opacity: .5;
transform: scale(.955);
transition: transform .28s ease, opacity .28s ease, border-color .28s ease;
}
.card.is-active {
opacity: 1;
transform: scale(1);
border-color: var(--line-2);
}
.card__art {
height: 136px;
border-radius: 12px;
background:
radial-gradient(130px 90px at 30% 18%, hsl(var(--h) 88% 64% / .92), transparent 70%),
linear-gradient(140deg, hsl(var(--h) 68% 44%), hsl(calc(var(--h) + 45) 62% 20%));
}
.card h2 { margin: .75rem .35rem .25rem; font-size: .95rem; }
.card p { margin: 0 .35rem; font-size: .8rem; color: var(--muted); }
/* ---------- progress + dots ---------- */
.progress {
height: .3rem;
margin: 0 1.35rem;
border-radius: 99px;
background: #22304f;
overflow: hidden;
}
.progress i {
display: block;
height: 100%;
width: 0;
border-radius: 99px;
background: linear-gradient(90deg, var(--accent), #b779ff);
}
.dots {
display: flex;
gap: .45rem;
justify-content: center;
padding: 1rem 0 .25rem;
}
.dots button {
width: 7px;
height: 7px;
padding: 0;
border: 0;
border-radius: 99px;
background: #3d4a70;
cursor: pointer;
transition: width .2s, background .2s;
}
.dots button[aria-current="true"] { width: 22px; background: var(--accent); }
/* ---------- footer ---------- */
.footbar {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: .6rem;
padding: .7rem 1.35rem 0;
color: var(--muted);
font-size: .8rem;
}
.status { margin: 0; }
.toggle { display: flex; align-items: center; gap: .45rem; cursor: pointer; }
.toggle input { accent-color: var(--accent); }
@media (max-width: 600px) {
.gallery { border-radius: 16px; }
.card { flex-basis: 78%; }
.footbar { justify-content: center; text-align: center; }
}
@media (prefers-reduced-motion: reduce) {
.card, .navbtn, .dots button, .progress i { transition: none; }
}/* Horizontal Scroll Gallery — scroll-snap rail driven by wheel, buttons, dots and keyboard. */
(() => {
const rail = document.getElementById("rail");
if (!rail) return;
const cards = Array.from(rail.querySelectorAll(".card"));
const prev = document.getElementById("prev");
const next = document.getElementById("next");
const dotsBox = document.getElementById("dots");
const bar = document.getElementById("bar");
const progress = document.getElementById("progress");
const status = document.getElementById("status");
const wheelToggle = document.getElementById("wheel");
const reduce = matchMedia("(prefers-reduced-motion: reduce)");
const behavior = () => (reduce.matches ? "auto" : "smooth");
let index = 0;
/* ---- dots ---- */
const dots = cards.map((card, i) => {
const b = document.createElement("button");
b.type = "button";
b.setAttribute("aria-label", "Go to slide " + (i + 1));
b.addEventListener("click", () => goTo(i));
dotsBox.appendChild(b);
return b;
});
/* ---- navigation ---- */
function goTo(i) {
i = Math.max(0, Math.min(cards.length - 1, i));
const left = cards[i].offsetLeft - rail.offsetLeft;
rail.scrollTo({ left, behavior: behavior() });
}
/* Nearest card to the rail's left scroll edge = the "current" slide. */
function nearestIndex() {
const origin = rail.scrollLeft + rail.offsetLeft;
let best = 0;
let bestDist = Infinity;
cards.forEach((card, i) => {
const d = Math.abs(card.offsetLeft - origin);
if (d < bestDist) { bestDist = d; best = i; }
});
return best;
}
function render() {
const max = rail.scrollWidth - rail.clientWidth;
const ratio = max > 0 ? rail.scrollLeft / max : 0;
bar.style.width = (ratio * 100).toFixed(2) + "%";
progress.setAttribute("aria-valuenow", Math.round(ratio * 100));
const i = nearestIndex();
if (i !== index) {
index = i;
const title = cards[i].querySelector("h2").textContent;
status.textContent = "Slide " + (i + 1) + " of " + cards.length + " — " + title;
}
cards.forEach((c, n) => c.classList.toggle("is-active", n === index));
dots.forEach((d, n) => d.setAttribute("aria-current", String(n === index)));
prev.disabled = rail.scrollLeft <= 1;
next.disabled = rail.scrollLeft >= max - 1;
}
/* Coalesce scroll events into one paint. */
let frame = 0;
rail.addEventListener("scroll", () => {
if (frame) return;
frame = requestAnimationFrame(() => { frame = 0; render(); });
}, { passive: true });
prev.addEventListener("click", () => goTo(index - 1));
next.addEventListener("click", () => goTo(index + 1));
/* ---- keyboard ---- */
rail.addEventListener("keydown", (e) => {
const map = { ArrowRight: index + 1, ArrowLeft: index - 1, Home: 0, End: cards.length - 1 };
if (!(e.key in map)) return;
e.preventDefault();
goTo(map[e.key]);
});
/* ---- wheel: map vertical deltas onto the horizontal axis ---- */
rail.addEventListener("wheel", (e) => {
if (!wheelToggle.checked) return;
// Let genuinely horizontal gestures (trackpad swipes) pass through untouched.
if (Math.abs(e.deltaX) > Math.abs(e.deltaY)) return;
const max = rail.scrollWidth - rail.clientWidth;
const atStart = rail.scrollLeft <= 0 && e.deltaY < 0;
const atEnd = rail.scrollLeft >= max && e.deltaY > 0;
if (atStart || atEnd) return; // release the page scroll at the edges
e.preventDefault();
// deltaMode 1 = lines, 2 = pages — normalise to pixels.
const unit = e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? rail.clientWidth : 1;
rail.scrollLeft += e.deltaY * unit;
}, { passive: false });
addEventListener("resize", render);
render();
})();<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Horizontal Scroll Gallery</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<main class="demo" data-demo="Horizontal Scroll Gallery">
<section class="gallery" aria-labelledby="gal-title">
<header class="gallery__head">
<h1 id="gal-title">Field Notes</h1>
<div class="gallery__nav">
<button type="button" class="navbtn" id="prev" aria-controls="rail" aria-label="Previous slide">‹</button>
<button type="button" class="navbtn" id="next" aria-controls="rail" aria-label="Next slide">›</button>
</div>
</header>
<div class="rail" id="rail" tabindex="0" role="region"
aria-label="Gallery — use the left and right arrow keys to move between slides">
<article class="card" style="--h:198" aria-label="Slide 1 of 6: Aurora">
<div class="card__art" role="presentation"></div>
<h2>Aurora</h2>
<p>Northern light bands over still water.</p>
</article>
<article class="card" style="--h:255" aria-label="Slide 2 of 6: Basalt">
<div class="card__art" role="presentation"></div>
<h2>Basalt</h2>
<p>Hexagonal columns at low tide.</p>
</article>
<article class="card" style="--h:22" aria-label="Slide 3 of 6: Ember">
<div class="card__art" role="presentation"></div>
<h2>Ember</h2>
<p>Late sun through canyon dust.</p>
</article>
<article class="card" style="--h:150" aria-label="Slide 4 of 6: Fathom">
<div class="card__art" role="presentation"></div>
<h2>Fathom</h2>
<p>Kelp shadows in shallow green.</p>
</article>
<article class="card" style="--h:300" aria-label="Slide 5 of 6: Quartz">
<div class="card__art" role="presentation"></div>
<h2>Quartz</h2>
<p>Cold facets under an overcast sky.</p>
</article>
<article class="card" style="--h:75" aria-label="Slide 6 of 6: Meadow">
<div class="card__art" role="presentation"></div>
<h2>Meadow</h2>
<p>Grass heads bending all one way.</p>
</article>
</div>
<div class="progress" role="progressbar" id="progress"
aria-label="Scroll position" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0">
<i id="bar"></i>
</div>
<nav class="dots" id="dots" aria-label="Jump to slide"></nav>
<div class="footbar">
<p class="status" id="status" role="status" aria-live="polite">Slide 1 of 6 — Aurora</p>
<label class="toggle">
<input type="checkbox" id="wheel" checked />
<span>Vertical wheel scrolls horizontally</span>
</label>
</div>
</section>
</main>
<script src="script.js"></script>
</body>
</html>import { useEffect, useState } from "react";
export function ScrollHorizontalGallery() {
const [progress, setProgress] = useState(0);
useEffect(() => {
const onScroll = () =>
setProgress(
Math.min(
100,
Math.round((scrollY / Math.max(1, document.body.scrollHeight - innerHeight)) * 100)
)
);
addEventListener("scroll", onScroll, { passive: true });
return () => removeEventListener("scroll", onScroll);
}, []);
return (
<section className="demo">
<h2>Horizontal Scroll Gallery</h2>
<div className="progress">
<i style={{ width: `${progress}%` }} />
</div>
<div style={{ minHeight: 420, paddingTop: 120 }}>
<article className="card">Scroll-linked content · {progress}%</article>
</div>
</section>
);
}Horizontal Scroll Gallery
A horizontal scroll-snap gallery that can be driven by wheel input or buttons.
Support notes
The CSS uses animation-timeline where available and a small JavaScript fallback where it is not. Keep the fallback because scroll-linked CSS support varies by browser.
Included demo
- Vanilla HTML, CSS, and JavaScript with zero external dependencies.
- React equivalent using the same interaction model.
- A Lab route for trying the behavior in isolation.
Integration checklist
Keep state updates separate from presentation, preserve semantic labels, and add persistence or server callbacks at the boundary where your product needs them.