網頁設計特效

捲動吸附

橫著一甩,它自己停在下一項正中。四條 CSS,零 JavaScript —— 而且慣性物理是瀏覽器自己的,這正是手寫版本永遠差一口氣的原因。

怎麼做到的

參數

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 打開就能用,不需要別的任何東西。

scroll-snap-gallery.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>

會翻車的地方

出處

@菜心视觉设计 (Douyin) · effects catalogued from a screen recording of alche.jp · 0:42