捲動吸附
橫著一甩,它自己停在下一項正中。四條 CSS,零 JavaScript —— 而且慣性物理是瀏覽器自己的,這正是手寫版本永遠差一口氣的原因。
怎麼做到的
scroll-snap-type: x mandatoryon the scroller — which axis snaps, and that it always must.proximityinstead means the browser snaps only when it feels like it, which reads as an intermittent bug rather than a softer setting.scroll-snap-align: centeron each child — which part of the child lines up with the snap position.scroll-padding-inline: 22%— where 'centred' actually is. This is the one people leave out, and then wonder why the first and last items cannot reach the middle.scroll-snap-stop: always— a fast flick advances one item instead of skipping three. Without it a gallery is unusable on a trackpad, and perfectly fine on the mouse the developer tested with.- 🩸 The slide width is
flex: 0 0 100%, not56%. A flex-basis percentage resolves against the container's content box, and the 22%padding-inlinehas already taken its bite — so56%would quietly hand you 56% of 56%, about a third of the rail, and the slides come out half the size you asked for. 100% of the content box is the 56% of the rail you wanted, and the neighbours peek by exactly the padding. - The snapping itself has no JavaScript. The script in the sample only lights the dots. Handing this to the browser means you inherit its momentum curve, its rubber-banding and its accessibility behaviour for free — all of which a wheel-event reimplementation has to fake, and none of which it fakes convincingly.
參數
- Snap
- x mandatory
- Align
- center
- Scroll padding
- 22% inline
- Slide width
- 100% of content box = 56% of rail
- Gap
- 16px
- Skip guard
- scroll-snap-stop: always
提示詞
把這段丟給任意一個寫程式的模型,就能從零長出這個效果。它把每個數值都寫死了 ——「絲滑」「現代感」這種詞每次生成出來的都不一樣。
Build a horizontal snapping gallery in pure CSS with no JavaScript for the snapping itself. On the flex scroll container set overflow-x: auto, scroll-snap-type: x mandatory (not proximity), scroll-padding-inline: 22% and scroll-behavior: smooth, with padding-inline of 22% so the first and last items can reach the centre. On each slide set flex: 0 0 100% — NOT 56%: a flex-basis percentage resolves against the container's content box, which the padding has already reduced, so 100% of that content box is the 56% of the rail you actually want — plus scroll-snap-align: center and scroll-snap-stop: always so a fast flick advances exactly one item. Hide the scrollbar with scrollbar-width: none and ::-webkit-scrollbar { display: none }. Do not reimplement momentum or snapping in a wheel handler — the browser's own physics is the point. Any JavaScript should be limited to secondary UI such as pagination dots, which find the nearest slide centre to the container's scroll midpoint. Set scroll-behavior: auto under prefers-reduced-motion: reduce.原始碼
整個檔案。上面那個樣板跑的就是它 —— 存成 .html 打開就能用,不需要別的任何東西。
<!doctype html>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Scroll snap</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { height: 100%; }
body {
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", sans-serif;
background: #08080d; color: #fff;
display: grid; grid-template-rows: 1fr auto; height: 100%;
}
/* The whole effect is these four declarations. No JS, no libraries, and the
browser keeps its own momentum physics — which is why it feels native and
a hand-written snap never quite does. */
.rail {
display: flex; gap: 16px;
overflow-x: auto;
scroll-snap-type: x mandatory; /* 1: this axis snaps, always */
scroll-padding-inline: 22%; /* 3: where "centred" actually is */
scroll-behavior: smooth;
padding: 22px 22%;
align-items: center;
scrollbar-width: none;
}
.rail::-webkit-scrollbar { display: none; }
.slide {
/* 🩸 A flex-basis percentage resolves against the container's CONTENT box,
which the 22% padding above has already taken a bite out of. `56%` here
would silently give you 56% of 56% — about a third of the rail — and the
slides come out half the size you asked for with nothing to explain why.
100% of the content box is exactly the 56% of the rail we actually want,
and it leaves the neighbours peeking by exactly the padding. */
flex: 0 0 100%;
aspect-ratio: 16 / 10;
border-radius: 12px;
scroll-snap-align: center; /* 2: which part of the child lines up */
position: relative; overflow: hidden;
/* 4: don't let a fast flick skip past a slide. `proximity` would allow it. */
scroll-snap-stop: always;
transition: transform 320ms cubic-bezier(0.23, 1, 0.32, 1);
}
.slide .tag {
position: absolute; left: 12px; bottom: 10px;
font-size: 12px; font-weight: 600; text-shadow: 0 1px 8px #000a;
}
.s1 { background: radial-gradient(60% 80% at 30% 25%, #ff5abf, transparent 60%), linear-gradient(140deg,#7b2ff7,#1a1030); }
.s2 { background: radial-gradient(60% 80% at 70% 30%, #29e0ff, transparent 60%), linear-gradient(140deg,#0a4d8c,#071427); }
.s3 { background: radial-gradient(60% 80% at 40% 70%, #ffd23e, transparent 60%), linear-gradient(140deg,#e0620d,#2a1206); }
.s4 { background: radial-gradient(60% 80% at 55% 35%, #9a8bff, transparent 60%), linear-gradient(140deg,#3b2bff,#0d0a24); }
.s5 { background: radial-gradient(60% 80% at 35% 45%, #00c2a8, transparent 60%), linear-gradient(140deg,#0b6b5c,#04211d); }
.foot {
padding: 0 22px 16px;
display: flex; align-items: center; justify-content: space-between;
font-size: 11px; letter-spacing: 0.16em; text-transform: uppercase; color: #ffffff5c;
}
.dots { display: flex; gap: 6px; }
.dots i { width: 5px; height: 5px; border-radius: 50%; background: #ffffff30; transition: background 200ms; }
.dots i.on { background: #fff; }
@media (prefers-reduced-motion: reduce) {
.rail { scroll-behavior: auto; }
.slide { transition: none; }
}
</style>
<div class="rail" id="rail">
<div class="slide s1"><span class="tag">Run for Money</span></div>
<div class="slide s2"><span class="tag">Shin Sekai</span></div>
<div class="slide s3"><span class="tag">Matsuken Samba</span></div>
<div class="slide s4"><span class="tag">Wear Go Land</span></div>
<div class="slide s5"><span class="tag">Discoat 2025SS</span></div>
</div>
<div class="foot">
<span>drag sideways · it locks</span>
<span class="dots" id="dots"></span>
</div>
<script>
// Everything below is only the dots. The snapping above needs no JavaScript.
const rail = document.getElementById("rail");
const dots = document.getElementById("dots");
const slides = [...rail.children];
slides.forEach(() => dots.appendChild(document.createElement("i")));
const mark = () => {
const mid = rail.scrollLeft + rail.clientWidth / 2;
let best = 0, bestD = Infinity;
slides.forEach((s, i) => {
const d = Math.abs(s.offsetLeft + s.offsetWidth / 2 - mid);
if (d < bestD) { bestD = d; best = i; }
});
[...dots.children].forEach((d, i) => d.classList.toggle("on", i === best));
};
rail.addEventListener("scroll", mark, { passive: true });
mark();
</script>會翻車的地方
- Hiding the scrollbar removes the only affordance that the row scrolls at all. Keep a visible cue — dots, a peeking next slide, an arrow — or people will never touch it.
mandatorycan trap a user: if a slide is taller than the viewport on a cross-axis snap, they can never rest between items to read it. That is whatproximityis genuinely for.scroll-snap-stop: alwaysis unsupported in older Safari — it degrades to skipping, not to breaking, which is the right way round but is worth knowing before you rely on one-at-a-time.
出處
@菜心视觉设计 (Douyin) · effects catalogued from a screen recording of alche.jp · 0:42