12 CSS Full Page Sections03 / 12

Pure CSSMIT licensed

CSS Scroll Snap Stop to Prevent Skipping Sections

Two live scrollers side by side, identical except for one declaration. Flick the left one hard and it flies past two panels; flick the right one and scroll-snap-stop: always catches it at the very next panel. Only a handful of production sites use this property — this demo shows what it fixes, and the one case where it hurts.

Published

Live Demo
Try it

The code

<div class="fps-03">
  <input class="fps-03__sr" type="checkbox" id="fps-03-dark">
  <label class="fps-03__theme" for="fps-03-dark" title="Switch light or dark theme">
    <svg class="fps-03__sun" viewBox="0 0 24 24" fill="none" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="4.4" stroke="currentColor" stroke-width="1.7"/><path d="M12 2.6v2.3M12 19.1v2.3M2.6 12h2.3M19.1 12h2.3M5.3 5.3l1.7 1.7M17 17l1.7 1.7M18.7 5.3L17 7M7 17l-1.7 1.7" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg>
    <svg class="fps-03__moon" viewBox="0 0 24 24" fill="none" aria-hidden="true" focusable="false"><path d="M20.5 14.4A8.4 8.4 0 0 1 9.6 3.5 8.6 8.6 0 1 0 20.5 14.4Z" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/></svg>
    <span class="fps-03__vh">Switch light or dark theme</span>
  </label>

  <div class="fps-03__scroller" tabindex="0" role="group" aria-label="Scroll snap stop demonstration, three panels">

    <section class="fps-03__panel" id="fps-03-s1" aria-labelledby="fps-03-h1">
      <div class="fps-03__stage">
        <p class="fps-03__eyebrow"><span class="fps-03__num">01 / 03</span><span class="fps-03__mono">scroll-snap-stop: always</span></p>
        <h2 class="fps-03__title" id="fps-03-h1">Stop the flick that skips your best section</h2>
        <p class="fps-03__lead">Default snapping only decides where momentum <em>ends</em>. One hard swipe can therefore fly past two panels &mdash; and past whatever argument they carried. <code>scroll-snap-stop:always</code> makes each gesture advance exactly one panel.</p>
        <div class="fps-03__ab" aria-hidden="false">
          <figure class="fps-03__mini">
            <figcaption><span class="fps-03__tag fps-03__tag--no">normal</span>Flick hard &mdash; it skips</figcaption>
            <div class="fps-03__mscroll" tabindex="0" role="group" aria-label="Scroller without snap stop, five panels">
              <div class="fps-03__mpanel" style="--h:14"><span>01</span><p>Problem</p></div>
              <div class="fps-03__mpanel" style="--h:34"><span>02</span><p>Approach</p></div>
              <div class="fps-03__mpanel" style="--h:150"><span>03</span><p>Proof</p></div>
              <div class="fps-03__mpanel" style="--h:200"><span>04</span><p>Pricing</p></div>
              <div class="fps-03__mpanel" style="--h:265"><span>05</span><p>Sign up</p></div>
            </div>
          </figure>
          <figure class="fps-03__mini">
            <figcaption><span class="fps-03__tag fps-03__tag--yes">always</span>Flick hard &mdash; it holds</figcaption>
            <div class="fps-03__mscroll fps-03__mscroll--stop" tabindex="0" role="group" aria-label="Scroller with scroll snap stop always, five panels">
              <div class="fps-03__mpanel" style="--h:14"><span>01</span><p>Problem</p></div>
              <div class="fps-03__mpanel" style="--h:34"><span>02</span><p>Approach</p></div>
              <div class="fps-03__mpanel" style="--h:150"><span>03</span><p>Proof</p></div>
              <div class="fps-03__mpanel" style="--h:200"><span>04</span><p>Pricing</p></div>
              <div class="fps-03__mpanel" style="--h:265"><span>05</span><p>Sign up</p></div>
            </div>
          </figure>
        </div>
        <p class="fps-03__note">Your browser does not support <code>scroll-snap-stop</code>, so both scrollers behave identically &mdash; the pattern degrades to <code>normal</code>, never to broken.</p>
      </div>
    </section>

    <section class="fps-03__panel" id="fps-03-s2" aria-labelledby="fps-03-h2">
      <div class="fps-03__stage">
        <p class="fps-03__eyebrow"><span class="fps-03__num">02 / 03</span><span class="fps-03__mono">where the property goes</span></p>
        <h2 class="fps-03__title fps-03__title--sm" id="fps-03-h2">On the panel. Never on the scroller.</h2>
        <div class="fps-03__pair">
          <div class="fps-03__box fps-03__box--no">
            <p class="fps-03__bh">Does nothing</p>
            <pre><code>.scroller{
  scroll-snap-type:y mandatory;
  scroll-snap-stop:always; /* ignored */
}</code></pre>
          </div>
          <div class="fps-03__box fps-03__box--yes">
            <p class="fps-03__bh">Works</p>
            <pre><code>.panel{
  scroll-snap-align:start;
  scroll-snap-stop:always; /* enforced */
}</code></pre>
          </div>
        </div>
        <p class="fps-03__lead">It also requires an active <code>scroll-snap-align</code> on the same element &mdash; a snap stop with nothing to snap to is inert. And it constrains user gestures only: <code>scrollTo()</code> and anchor jumps still travel as far as you send them.</p>
      </div>
    </section>

    <section class="fps-03__panel" id="fps-03-s3" aria-labelledby="fps-03-h3">
      <div class="fps-03__stage">
        <p class="fps-03__eyebrow"><span class="fps-03__num">03 / 03</span><span class="fps-03__mono">when not to use it</span></p>
        <h2 class="fps-03__title fps-03__title--sm" id="fps-03-h3">Short sequences yes, long pages no</h2>
        <dl class="fps-03__table">
          <div class="fps-03__row"><dt>3&ndash;6 panels</dt><dd><strong>Use it.</strong> Onboarding, a product tour, a pitch. Every panel carries a point the reader must not skip past.</dd></div>
          <div class="fps-03__row"><dt>7+ panels</dt><dd><strong>Leave it off.</strong> One gesture per panel becomes friction, and skimmers &mdash; most of your traffic &mdash; cannot skim.</dd></div>
          <div class="fps-03__row"><dt>Selective</dt><dd><strong>Best of both.</strong> Put <code>always</code> on the two panels that matter (pricing, consent) and let the rest stay <code>normal</code>.</dd></div>
          <div class="fps-03__row"><dt>Reduced motion</dt><dd><strong>Reset it.</strong> With snapping disabled, forcing one panel per gesture makes no sense &mdash; ship <code>scroll-snap-stop:normal</code> in that media query.</dd></div>
        </dl>
      </div>
    </section>

  </div>
</div>
@import url('https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700&family=Geist+Mono:wght@400;500&display=swap');

.fps-03 {
  --bg: #fdfaf6;
  --panel: #ffffff;
  --panel2: #f6f1ea;
  --ink: #171310;
  --muted: rgba(23,19,16,.62);
  --line: rgba(23,19,16,.12);
  --accent: #c2410c;
  --yes: #15803d;
  --no: #b91c1c;
  --sans: 'Geist',system-ui,-apple-system,'Segoe UI',sans-serif;
  --mono: 'Geist Mono',ui-monospace,'SFMono-Regular',Menlo,monospace;
  --h-scroller: clamp(11rem,30vh,17rem);
  width: 100%;
  min-height: 100vh;
  display: block;
  position: relative;
  isolation: isolate;
  background: var(--bg);
  color: var(--ink);
  font-family: var(--sans);
  -webkit-font-smoothing: antialiased;
}

@supports (color: oklch(50% .1 250)) {
  .fps-03 {
    --accent: oklch(56% .18 42);
    --yes: oklch(52% .14 148);
    --no: oklch(52% .19 27);
    --ink: oklch(16% .015 45);
    --bg: oklch(98% .008 75);
    --panel2: oklch(95% .014 75);
  }
}

.fps-03 * {
  box-sizing: border-box;
}

.fps-03 h2,
.fps-03 p,
.fps-03 dl,
.fps-03 dd,
.fps-03 figure,
.fps-03 figcaption,
.fps-03 pre {
  margin: 0;
}

.fps-03 code {
  font-family: var(--mono);
  font-size: .92em;
}

.fps-03__sr,
.fps-03__vh {
  position: absolute;
  inline-size: 1px;
  block-size: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

.fps-03__theme {
  position: fixed;
  inset-block-start: 1.15rem;
  inset-inline-end: 1.15rem;
  z-index: 80;
  inline-size: 2.5rem;
  block-size: 2.5rem;
  display: grid;
  place-items: center;
  border-radius: 999px;
  cursor: pointer;
  color: var(--ink);
  background: rgba(255,255,255,.62);
  border: 1px solid var(--line);
  -webkit-backdrop-filter: blur(10px) saturate(150%);
  backdrop-filter: blur(10px) saturate(150%);
  box-shadow: 0 8px 24px -12px rgba(23,19,16,.35);
}

.fps-03__theme::after {
  content: '';
  position: absolute;
  inset: -2px;
  border-radius: inherit;
}

.fps-03__theme svg {
  inline-size: 1.05rem;
  block-size: 1.05rem;
}

.fps-03__moon {
  display: none;
}

.fps-03__sr:focus-visible + .fps-03__theme {
  outline: 2px solid var(--accent);
  outline-offset: 3px;
}

.fps-03__scroller {
  block-size: 100dvh;
  overflow-y: auto;
  overflow-x: hidden;
  scroll-snap-type: y mandatory;
  scroll-behavior: smooth;
  overscroll-behavior-y: contain;
  scrollbar-width: none;
}

.fps-03__scroller::-webkit-scrollbar {
  width: 0;
  height: 0;
}

.fps-03__scroller:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: -2px;
}

.fps-03__panel {
  position: relative;
  min-block-size: 100dvh;
  scroll-snap-align: start;
  background: var(--panel);
  display: flex;
  align-items: center;
  justify-content: center;
  padding: clamp(2rem,5vh,3.5rem) clamp(1.25rem,5vw,4.5rem) calc(clamp(2rem,5vh,3.5rem) + 4rem);
}

.fps-03__panel:nth-child(even) {
  background: var(--panel2);
}

.fps-03__panel + .fps-03__panel {
  margin-block-start: -4rem;
  border-start-start-radius: 1.75rem;
  border-start-end-radius: 1.75rem;
  border-block-start: 1px solid var(--line);
  box-shadow: 0 -30px 64px -34px rgba(23,19,16,.24);
}

@supports not (height: 100dvh) {
  .fps-03__scroller {
    block-size: 100vh;
  }

  .fps-03__panel {
    min-block-size: 100vh;
  }
}

.fps-03__stage {
  inline-size: min(68rem,100%);
  display: grid;
  gap: clamp(.9rem,2.2vh,1.4rem);
  justify-items: start;
}

.fps-03__eyebrow {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: .7rem;
}

.fps-03__num {
  font-family: var(--mono);
  font-size: .7rem;
  letter-spacing: .14em;
  text-transform: uppercase;
  color: var(--accent);
}

.fps-03__mono {
  font-family: var(--mono);
  font-size: .7rem;
  color: var(--muted);
  padding: .22rem .5rem;
  border: 1px solid var(--line);
  border-radius: .45rem;
}

.fps-03__title {
  font-size: clamp(1.95rem,5.2vw,3.6rem);
  line-height: 1.04;
  letter-spacing: -.04em;
  font-weight: 600;
  max-inline-size: 22ch;
  text-wrap: balance;
}

.fps-03__title--sm {
  font-size: clamp(1.7rem,3.6vw,2.6rem);
  letter-spacing: -.03em;
  max-inline-size: 28ch;
}

.fps-03__lead {
  font-size: clamp(.98rem,1.35vw,1.12rem);
  line-height: 1.6;
  color: var(--muted);
  max-inline-size: 60ch;
  text-wrap: pretty;
}

.fps-03__ab {
  display: grid;
  grid-template-columns: repeat(auto-fit,minmax(14rem,1fr));
  gap: clamp(.9rem,2.5vw,1.75rem);
  inline-size: 100%;
}

.fps-03__mini {
  display: grid;
  gap: .6rem;
}

.fps-03__mini figcaption {
  display: flex;
  align-items: center;
  gap: .55rem;
  font-size: .85rem;
  color: var(--muted);
}

.fps-03__tag {
  font-family: var(--mono);
  font-size: .66rem;
  letter-spacing: .08em;
  padding: .2rem .45rem;
  border-radius: .4rem;
  border: 1px solid currentColor;
}

.fps-03__tag--yes {
  color: var(--yes);
}

.fps-03__tag--no {
  color: var(--no);
}

.fps-03__mscroll {
  block-size: var(--h-scroller);
  overflow-y: auto;
  scroll-snap-type: y mandatory;
  scroll-behavior: smooth;
  overscroll-behavior: contain;
  border-radius: 1.1rem;
  border: 1px solid var(--line);
  scrollbar-width: none;
  background: color-mix(in srgb,var(--ink) 4%,transparent);
}

.fps-03__mscroll::-webkit-scrollbar {
  width: 0;
  height: 0;
}

.fps-03__mscroll:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}

.fps-03__mpanel {
  block-size: calc(var(--h-scroller) - 1.8rem);
  scroll-snap-align: start;
  display: grid;
  align-content: center;
  gap: .15rem;
  padding-inline: clamp(.9rem,2vw,1.4rem);
  background: hsl(var(--h) 70% 96%);
  border-block-end: 1px solid var(--line);
}

@supports (background: oklch(90% .05 40)) {
  .fps-03__mpanel {
    background: oklch(95% .045 calc(var(--h) * 1deg));
  }
}

.fps-03__mpanel span {
  font-family: var(--mono);
  font-size: clamp(1.6rem,4vw,2.4rem);
  font-variant-numeric: tabular-nums;
  color: hsl(var(--h) 65% 32%);
  letter-spacing: -.03em;
}

.fps-03__mpanel p {
  font-size: .85rem;
  color: hsl(var(--h) 30% 30%);
  letter-spacing: .02em;
}

@supports (color: oklch(40% .1 40)) {
  .fps-03__mpanel span {
    color: oklch(48% .16 calc(var(--h) * 1deg));
  }

  .fps-03__mpanel p {
    color: oklch(45% .06 calc(var(--h) * 1deg));
  }
}

@supports (scroll-snap-stop: always) {
  .fps-03__mscroll--stop .fps-03__mpanel {
    scroll-snap-stop: always;
  }
}

.fps-03__note {
  display: none;
  font-size: .85rem;
  line-height: 1.5;
  color: var(--no);
  padding: .7rem .9rem;
  border-radius: .75rem;
  border: 1px dashed color-mix(in srgb,var(--no) 45%,transparent);
}

@supports not (scroll-snap-stop: always) {
  .fps-03__note {
    display: block;
  }
}

.fps-03__pair {
  display: grid;
  grid-template-columns: repeat(auto-fit,minmax(15rem,1fr));
  gap: clamp(.85rem,2vw,1.5rem);
  inline-size: 100%;
}

.fps-03__box {
  border-radius: 1.15rem;
  border: 1px solid var(--line);
  overflow: hidden;
  background: var(--bg);
}

.fps-03__bh {
  font-family: var(--mono);
  font-size: .68rem;
  letter-spacing: .12em;
  text-transform: uppercase;
  padding: .7rem .9rem;
  border-block-end: 1px solid var(--line);
}

.fps-03__box--yes .fps-03__bh {
  color: var(--yes);
}

.fps-03__box--no .fps-03__bh {
  color: var(--no);
}

.fps-03__box pre {
  padding: .95rem .9rem 1.15rem;
  overflow-x: auto;
}

.fps-03__box code {
  font-size: clamp(.76rem,1vw,.86rem);
  line-height: 1.7;
  color: var(--muted);
  white-space: pre;
}

.fps-03__table {
  inline-size: 100%;
  display: grid;
  gap: .4rem;
}

.fps-03__row {
  display: grid;
  grid-template-columns: minmax(6.5rem,9rem) 1fr;
  gap: clamp(.75rem,2vw,1.75rem);
  padding: clamp(.75rem,1.5vh,1.05rem) 0;
  border-block-start: 1px solid var(--line);
}

.fps-03__row dt {
  font-family: var(--mono);
  font-size: .86rem;
  color: var(--accent);
}

.fps-03__row dd {
  font-size: .96rem;
  line-height: 1.55;
  color: var(--muted);
  text-wrap: pretty;
}

.fps-03__row dd strong {
  color: var(--ink);
  font-weight: 550;
}

.fps-03[data-theme="dark"],
.fps-03:has(#fps-03-dark:checked) {
  --bg: #0a0806;
  --panel: #0f0c09;
  --panel2: #161210;
  --ink: #f8f4ef;
  --muted: rgba(248,244,239,.62);
  --line: rgba(248,244,239,.14);
  --accent: #fdba74;
  --yes: #86efac;
  --no: #fca5a5;
}

.fps-03:has(#fps-03-dark:checked) .fps-03__theme {
  background: rgba(255,255,255,.07);
}

.fps-03:has(#fps-03-dark:checked) .fps-03__sun {
  display: none;
}

.fps-03:has(#fps-03-dark:checked) .fps-03__moon {
  display: block;
}

.fps-03:has(#fps-03-dark:checked) .fps-03__mpanel {
  background: hsl(var(--h) 40% 12%);
}

.fps-03:has(#fps-03-dark:checked) .fps-03__mpanel span {
  color: hsl(var(--h) 80% 78%);
}

.fps-03:has(#fps-03-dark:checked) .fps-03__mpanel p {
  color: hsl(var(--h) 25% 72%);
}

@media (prefers-color-scheme: dark) {
  .fps-03:not([data-theme="light"]):not(:has(#fps-03-dark:checked)) {
    --bg: #0a0806;
    --panel: #0f0c09;
    --panel2: #161210;
    --ink: #f8f4ef;
    --muted: rgba(248,244,239,.62);
    --line: rgba(248,244,239,.14);
    --accent: #fdba74;
    --yes: #86efac;
    --no: #fca5a5;
  }

  .fps-03:not([data-theme="light"]):not(:has(#fps-03-dark:checked)) .fps-03__theme {
    background: rgba(255,255,255,.07);
  }

  .fps-03:not([data-theme="light"]):not(:has(#fps-03-dark:checked)) .fps-03__sun {
    display: none;
  }

  .fps-03:not([data-theme="light"]):not(:has(#fps-03-dark:checked)) .fps-03__moon {
    display: block;
  }

  .fps-03:not([data-theme="light"]):not(:has(#fps-03-dark:checked)) .fps-03__mpanel {
    background: hsl(var(--h) 40% 12%);
  }

  .fps-03:not([data-theme="light"]):not(:has(#fps-03-dark:checked)) .fps-03__mpanel span {
    color: hsl(var(--h) 80% 78%);
  }

  .fps-03:not([data-theme="light"]):not(:has(#fps-03-dark:checked)) .fps-03__mpanel p {
    color: hsl(var(--h) 25% 72%);
  }
}

@supports not selector(:has(*)) {
  .fps-03__theme {
    display: none;
  }
}

@media (max-height: 40rem) {
  .fps-03 {
    --h-scroller: clamp(7.5rem,24vh,17rem);
  }

  .fps-03__scroller {
    scroll-snap-type: y proximity;
  }

  .fps-03__panel {
    padding-block: 1.25rem 3rem;
  }

  .fps-03__stage {
    gap: .7rem;
  }
}

@media (prefers-reduced-motion: reduce) {
  .fps-03__scroller,
    .fps-03__mscroll {
    scroll-snap-type: none;
    scroll-behavior: auto;
  }

  .fps-03__mscroll--stop .fps-03__mpanel {
    scroll-snap-stop: normal;
  }

  .fps-03 *,
    .fps-03 *::before,
    .fps-03 *::after {
    animation: none !important;
    transition: none !important;
  }
}

@media (forced-colors: active) {
  .fps-03__panel,
    .fps-03__box,
    .fps-03__mscroll,
    .fps-03__mpanel,
    .fps-03__theme,
    .fps-03__tag {
    border-color: CanvasText;
    box-shadow: none;
    background: Canvas;
    color: CanvasText;
    -webkit-backdrop-filter: none;
    backdrop-filter: none;
  }
}
Paste this into ChatGPT, Claude, Cursor, or any coding assistant. The block below is pre-framed with everything the AI needs to integrate this demo into your project — markup, styles, scoping notes, and the source URL. Hit Copy and paste straight into your chat.
Here's a working CSS Full Page Section from CodeFronts. Use it as-is or adapt to your framework. All classes are scoped under a unique prefix so the code won't collide with your existing styles. MIT licensed.
Demo: CSS Scroll Snap Stop to Prevent Skipping Sections
Source: https://codefronts.com/layouts/css-full-page-sections/css-scroll-snap-stop-to-prevent-skipping-sections/

Two live scrollers side by side, identical except for one declaration. Flick the left one hard and it flies past two panels; flick the right one and scroll-snap-stop: always catches it at the very next panel. Only a handful of production sites use this property &mdash; this demo shows what it fixes, and the one case where it hurts.
## HTML
```html
<div class="fps-03">
  <input class="fps-03__sr" type="checkbox" id="fps-03-dark">
  <label class="fps-03__theme" for="fps-03-dark" title="Switch light or dark theme">
    <svg class="fps-03__sun" viewBox="0 0 24 24" fill="none" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="4.4" stroke="currentColor" stroke-width="1.7"/><path d="M12 2.6v2.3M12 19.1v2.3M2.6 12h2.3M19.1 12h2.3M5.3 5.3l1.7 1.7M17 17l1.7 1.7M18.7 5.3L17 7M7 17l-1.7 1.7" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg>
    <svg class="fps-03__moon" viewBox="0 0 24 24" fill="none" aria-hidden="true" focusable="false"><path d="M20.5 14.4A8.4 8.4 0 0 1 9.6 3.5 8.6 8.6 0 1 0 20.5 14.4Z" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/></svg>
    <span class="fps-03__vh">Switch light or dark theme</span>
  </label>

  <div class="fps-03__scroller" tabindex="0" role="group" aria-label="Scroll snap stop demonstration, three panels">

    <section class="fps-03__panel" id="fps-03-s1" aria-labelledby="fps-03-h1">
      <div class="fps-03__stage">
        <p class="fps-03__eyebrow"><span class="fps-03__num">01 / 03</span><span class="fps-03__mono">scroll-snap-stop: always</span></p>
        <h2 class="fps-03__title" id="fps-03-h1">Stop the flick that skips your best section</h2>
        <p class="fps-03__lead">Default snapping only decides where momentum <em>ends</em>. One hard swipe can therefore fly past two panels &mdash; and past whatever argument they carried. <code>scroll-snap-stop:always</code> makes each gesture advance exactly one panel.</p>
        <div class="fps-03__ab" aria-hidden="false">
          <figure class="fps-03__mini">
            <figcaption><span class="fps-03__tag fps-03__tag--no">normal</span>Flick hard &mdash; it skips</figcaption>
            <div class="fps-03__mscroll" tabindex="0" role="group" aria-label="Scroller without snap stop, five panels">
              <div class="fps-03__mpanel" style="--h:14"><span>01</span><p>Problem</p></div>
              <div class="fps-03__mpanel" style="--h:34"><span>02</span><p>Approach</p></div>
              <div class="fps-03__mpanel" style="--h:150"><span>03</span><p>Proof</p></div>
              <div class="fps-03__mpanel" style="--h:200"><span>04</span><p>Pricing</p></div>
              <div class="fps-03__mpanel" style="--h:265"><span>05</span><p>Sign up</p></div>
            </div>
          </figure>
          <figure class="fps-03__mini">
            <figcaption><span class="fps-03__tag fps-03__tag--yes">always</span>Flick hard &mdash; it holds</figcaption>
            <div class="fps-03__mscroll fps-03__mscroll--stop" tabindex="0" role="group" aria-label="Scroller with scroll snap stop always, five panels">
              <div class="fps-03__mpanel" style="--h:14"><span>01</span><p>Problem</p></div>
              <div class="fps-03__mpanel" style="--h:34"><span>02</span><p>Approach</p></div>
              <div class="fps-03__mpanel" style="--h:150"><span>03</span><p>Proof</p></div>
              <div class="fps-03__mpanel" style="--h:200"><span>04</span><p>Pricing</p></div>
              <div class="fps-03__mpanel" style="--h:265"><span>05</span><p>Sign up</p></div>
            </div>
          </figure>
        </div>
        <p class="fps-03__note">Your browser does not support <code>scroll-snap-stop</code>, so both scrollers behave identically &mdash; the pattern degrades to <code>normal</code>, never to broken.</p>
      </div>
    </section>

    <section class="fps-03__panel" id="fps-03-s2" aria-labelledby="fps-03-h2">
      <div class="fps-03__stage">
        <p class="fps-03__eyebrow"><span class="fps-03__num">02 / 03</span><span class="fps-03__mono">where the property goes</span></p>
        <h2 class="fps-03__title fps-03__title--sm" id="fps-03-h2">On the panel. Never on the scroller.</h2>
        <div class="fps-03__pair">
          <div class="fps-03__box fps-03__box--no">
            <p class="fps-03__bh">Does nothing</p>
            <pre><code>.scroller{
  scroll-snap-type:y mandatory;
  scroll-snap-stop:always; /* ignored */
}</code></pre>
          </div>
          <div class="fps-03__box fps-03__box--yes">
            <p class="fps-03__bh">Works</p>
            <pre><code>.panel{
  scroll-snap-align:start;
  scroll-snap-stop:always; /* enforced */
}</code></pre>
          </div>
        </div>
        <p class="fps-03__lead">It also requires an active <code>scroll-snap-align</code> on the same element &mdash; a snap stop with nothing to snap to is inert. And it constrains user gestures only: <code>scrollTo()</code> and anchor jumps still travel as far as you send them.</p>
      </div>
    </section>

    <section class="fps-03__panel" id="fps-03-s3" aria-labelledby="fps-03-h3">
      <div class="fps-03__stage">
        <p class="fps-03__eyebrow"><span class="fps-03__num">03 / 03</span><span class="fps-03__mono">when not to use it</span></p>
        <h2 class="fps-03__title fps-03__title--sm" id="fps-03-h3">Short sequences yes, long pages no</h2>
        <dl class="fps-03__table">
          <div class="fps-03__row"><dt>3&ndash;6 panels</dt><dd><strong>Use it.</strong> Onboarding, a product tour, a pitch. Every panel carries a point the reader must not skip past.</dd></div>
          <div class="fps-03__row"><dt>7+ panels</dt><dd><strong>Leave it off.</strong> One gesture per panel becomes friction, and skimmers &mdash; most of your traffic &mdash; cannot skim.</dd></div>
          <div class="fps-03__row"><dt>Selective</dt><dd><strong>Best of both.</strong> Put <code>always</code> on the two panels that matter (pricing, consent) and let the rest stay <code>normal</code>.</dd></div>
          <div class="fps-03__row"><dt>Reduced motion</dt><dd><strong>Reset it.</strong> With snapping disabled, forcing one panel per gesture makes no sense &mdash; ship <code>scroll-snap-stop:normal</code> in that media query.</dd></div>
        </dl>
      </div>
    </section>

  </div>
</div>
```
## CSS
```css
@import url('https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700&family=Geist+Mono:wght@400;500&display=swap');

.fps-03 {
  --bg: #fdfaf6;
  --panel: #ffffff;
  --panel2: #f6f1ea;
  --ink: #171310;
  --muted: rgba(23,19,16,.62);
  --line: rgba(23,19,16,.12);
  --accent: #c2410c;
  --yes: #15803d;
  --no: #b91c1c;
  --sans: 'Geist',system-ui,-apple-system,'Segoe UI',sans-serif;
  --mono: 'Geist Mono',ui-monospace,'SFMono-Regular',Menlo,monospace;
  --h-scroller: clamp(11rem,30vh,17rem);
  width: 100%;
  min-height: 100vh;
  display: block;
  position: relative;
  isolation: isolate;
  background: var(--bg);
  color: var(--ink);
  font-family: var(--sans);
  -webkit-font-smoothing: antialiased;
}

@supports (color: oklch(50% .1 250)) {
  .fps-03 {
    --accent: oklch(56% .18 42);
    --yes: oklch(52% .14 148);
    --no: oklch(52% .19 27);
    --ink: oklch(16% .015 45);
    --bg: oklch(98% .008 75);
    --panel2: oklch(95% .014 75);
  }
}

.fps-03 * {
  box-sizing: border-box;
}

.fps-03 h2,
.fps-03 p,
.fps-03 dl,
.fps-03 dd,
.fps-03 figure,
.fps-03 figcaption,
.fps-03 pre {
  margin: 0;
}

.fps-03 code {
  font-family: var(--mono);
  font-size: .92em;
}

.fps-03__sr,
.fps-03__vh {
  position: absolute;
  inline-size: 1px;
  block-size: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

.fps-03__theme {
  position: fixed;
  inset-block-start: 1.15rem;
  inset-inline-end: 1.15rem;
  z-index: 80;
  inline-size: 2.5rem;
  block-size: 2.5rem;
  display: grid;
  place-items: center;
  border-radius: 999px;
  cursor: pointer;
  color: var(--ink);
  background: rgba(255,255,255,.62);
  border: 1px solid var(--line);
  -webkit-backdrop-filter: blur(10px) saturate(150%);
  backdrop-filter: blur(10px) saturate(150%);
  box-shadow: 0 8px 24px -12px rgba(23,19,16,.35);
}

.fps-03__theme::after {
  content: '';
  position: absolute;
  inset: -2px;
  border-radius: inherit;
}

.fps-03__theme svg {
  inline-size: 1.05rem;
  block-size: 1.05rem;
}

.fps-03__moon {
  display: none;
}

.fps-03__sr:focus-visible + .fps-03__theme {
  outline: 2px solid var(--accent);
  outline-offset: 3px;
}

.fps-03__scroller {
  block-size: 100dvh;
  overflow-y: auto;
  overflow-x: hidden;
  scroll-snap-type: y mandatory;
  scroll-behavior: smooth;
  overscroll-behavior-y: contain;
  scrollbar-width: none;
}

.fps-03__scroller::-webkit-scrollbar {
  width: 0;
  height: 0;
}

.fps-03__scroller:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: -2px;
}

.fps-03__panel {
  position: relative;
  min-block-size: 100dvh;
  scroll-snap-align: start;
  background: var(--panel);
  display: flex;
  align-items: center;
  justify-content: center;
  padding: clamp(2rem,5vh,3.5rem) clamp(1.25rem,5vw,4.5rem) calc(clamp(2rem,5vh,3.5rem) + 4rem);
}

.fps-03__panel:nth-child(even) {
  background: var(--panel2);
}

.fps-03__panel + .fps-03__panel {
  margin-block-start: -4rem;
  border-start-start-radius: 1.75rem;
  border-start-end-radius: 1.75rem;
  border-block-start: 1px solid var(--line);
  box-shadow: 0 -30px 64px -34px rgba(23,19,16,.24);
}

@supports not (height: 100dvh) {
  .fps-03__scroller {
    block-size: 100vh;
  }

  .fps-03__panel {
    min-block-size: 100vh;
  }
}

.fps-03__stage {
  inline-size: min(68rem,100%);
  display: grid;
  gap: clamp(.9rem,2.2vh,1.4rem);
  justify-items: start;
}

.fps-03__eyebrow {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: .7rem;
}

.fps-03__num {
  font-family: var(--mono);
  font-size: .7rem;
  letter-spacing: .14em;
  text-transform: uppercase;
  color: var(--accent);
}

.fps-03__mono {
  font-family: var(--mono);
  font-size: .7rem;
  color: var(--muted);
  padding: .22rem .5rem;
  border: 1px solid var(--line);
  border-radius: .45rem;
}

.fps-03__title {
  font-size: clamp(1.95rem,5.2vw,3.6rem);
  line-height: 1.04;
  letter-spacing: -.04em;
  font-weight: 600;
  max-inline-size: 22ch;
  text-wrap: balance;
}

.fps-03__title--sm {
  font-size: clamp(1.7rem,3.6vw,2.6rem);
  letter-spacing: -.03em;
  max-inline-size: 28ch;
}

.fps-03__lead {
  font-size: clamp(.98rem,1.35vw,1.12rem);
  line-height: 1.6;
  color: var(--muted);
  max-inline-size: 60ch;
  text-wrap: pretty;
}

.fps-03__ab {
  display: grid;
  grid-template-columns: repeat(auto-fit,minmax(14rem,1fr));
  gap: clamp(.9rem,2.5vw,1.75rem);
  inline-size: 100%;
}

.fps-03__mini {
  display: grid;
  gap: .6rem;
}

.fps-03__mini figcaption {
  display: flex;
  align-items: center;
  gap: .55rem;
  font-size: .85rem;
  color: var(--muted);
}

.fps-03__tag {
  font-family: var(--mono);
  font-size: .66rem;
  letter-spacing: .08em;
  padding: .2rem .45rem;
  border-radius: .4rem;
  border: 1px solid currentColor;
}

.fps-03__tag--yes {
  color: var(--yes);
}

.fps-03__tag--no {
  color: var(--no);
}

.fps-03__mscroll {
  block-size: var(--h-scroller);
  overflow-y: auto;
  scroll-snap-type: y mandatory;
  scroll-behavior: smooth;
  overscroll-behavior: contain;
  border-radius: 1.1rem;
  border: 1px solid var(--line);
  scrollbar-width: none;
  background: color-mix(in srgb,var(--ink) 4%,transparent);
}

.fps-03__mscroll::-webkit-scrollbar {
  width: 0;
  height: 0;
}

.fps-03__mscroll:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}

.fps-03__mpanel {
  block-size: calc(var(--h-scroller) - 1.8rem);
  scroll-snap-align: start;
  display: grid;
  align-content: center;
  gap: .15rem;
  padding-inline: clamp(.9rem,2vw,1.4rem);
  background: hsl(var(--h) 70% 96%);
  border-block-end: 1px solid var(--line);
}

@supports (background: oklch(90% .05 40)) {
  .fps-03__mpanel {
    background: oklch(95% .045 calc(var(--h) * 1deg));
  }
}

.fps-03__mpanel span {
  font-family: var(--mono);
  font-size: clamp(1.6rem,4vw,2.4rem);
  font-variant-numeric: tabular-nums;
  color: hsl(var(--h) 65% 32%);
  letter-spacing: -.03em;
}

.fps-03__mpanel p {
  font-size: .85rem;
  color: hsl(var(--h) 30% 30%);
  letter-spacing: .02em;
}

@supports (color: oklch(40% .1 40)) {
  .fps-03__mpanel span {
    color: oklch(48% .16 calc(var(--h) * 1deg));
  }

  .fps-03__mpanel p {
    color: oklch(45% .06 calc(var(--h) * 1deg));
  }
}

@supports (scroll-snap-stop: always) {
  .fps-03__mscroll--stop .fps-03__mpanel {
    scroll-snap-stop: always;
  }
}

.fps-03__note {
  display: none;
  font-size: .85rem;
  line-height: 1.5;
  color: var(--no);
  padding: .7rem .9rem;
  border-radius: .75rem;
  border: 1px dashed color-mix(in srgb,var(--no) 45%,transparent);
}

@supports not (scroll-snap-stop: always) {
  .fps-03__note {
    display: block;
  }
}

.fps-03__pair {
  display: grid;
  grid-template-columns: repeat(auto-fit,minmax(15rem,1fr));
  gap: clamp(.85rem,2vw,1.5rem);
  inline-size: 100%;
}

.fps-03__box {
  border-radius: 1.15rem;
  border: 1px solid var(--line);
  overflow: hidden;
  background: var(--bg);
}

.fps-03__bh {
  font-family: var(--mono);
  font-size: .68rem;
  letter-spacing: .12em;
  text-transform: uppercase;
  padding: .7rem .9rem;
  border-block-end: 1px solid var(--line);
}

.fps-03__box--yes .fps-03__bh {
  color: var(--yes);
}

.fps-03__box--no .fps-03__bh {
  color: var(--no);
}

.fps-03__box pre {
  padding: .95rem .9rem 1.15rem;
  overflow-x: auto;
}

.fps-03__box code {
  font-size: clamp(.76rem,1vw,.86rem);
  line-height: 1.7;
  color: var(--muted);
  white-space: pre;
}

.fps-03__table {
  inline-size: 100%;
  display: grid;
  gap: .4rem;
}

.fps-03__row {
  display: grid;
  grid-template-columns: minmax(6.5rem,9rem) 1fr;
  gap: clamp(.75rem,2vw,1.75rem);
  padding: clamp(.75rem,1.5vh,1.05rem) 0;
  border-block-start: 1px solid var(--line);
}

.fps-03__row dt {
  font-family: var(--mono);
  font-size: .86rem;
  color: var(--accent);
}

.fps-03__row dd {
  font-size: .96rem;
  line-height: 1.55;
  color: var(--muted);
  text-wrap: pretty;
}

.fps-03__row dd strong {
  color: var(--ink);
  font-weight: 550;
}

.fps-03[data-theme="dark"],
.fps-03:has(#fps-03-dark:checked) {
  --bg: #0a0806;
  --panel: #0f0c09;
  --panel2: #161210;
  --ink: #f8f4ef;
  --muted: rgba(248,244,239,.62);
  --line: rgba(248,244,239,.14);
  --accent: #fdba74;
  --yes: #86efac;
  --no: #fca5a5;
}

.fps-03:has(#fps-03-dark:checked) .fps-03__theme {
  background: rgba(255,255,255,.07);
}

.fps-03:has(#fps-03-dark:checked) .fps-03__sun {
  display: none;
}

.fps-03:has(#fps-03-dark:checked) .fps-03__moon {
  display: block;
}

.fps-03:has(#fps-03-dark:checked) .fps-03__mpanel {
  background: hsl(var(--h) 40% 12%);
}

.fps-03:has(#fps-03-dark:checked) .fps-03__mpanel span {
  color: hsl(var(--h) 80% 78%);
}

.fps-03:has(#fps-03-dark:checked) .fps-03__mpanel p {
  color: hsl(var(--h) 25% 72%);
}

@media (prefers-color-scheme: dark) {
  .fps-03:not([data-theme="light"]):not(:has(#fps-03-dark:checked)) {
    --bg: #0a0806;
    --panel: #0f0c09;
    --panel2: #161210;
    --ink: #f8f4ef;
    --muted: rgba(248,244,239,.62);
    --line: rgba(248,244,239,.14);
    --accent: #fdba74;
    --yes: #86efac;
    --no: #fca5a5;
  }

  .fps-03:not([data-theme="light"]):not(:has(#fps-03-dark:checked)) .fps-03__theme {
    background: rgba(255,255,255,.07);
  }

  .fps-03:not([data-theme="light"]):not(:has(#fps-03-dark:checked)) .fps-03__sun {
    display: none;
  }

  .fps-03:not([data-theme="light"]):not(:has(#fps-03-dark:checked)) .fps-03__moon {
    display: block;
  }

  .fps-03:not([data-theme="light"]):not(:has(#fps-03-dark:checked)) .fps-03__mpanel {
    background: hsl(var(--h) 40% 12%);
  }

  .fps-03:not([data-theme="light"]):not(:has(#fps-03-dark:checked)) .fps-03__mpanel span {
    color: hsl(var(--h) 80% 78%);
  }

  .fps-03:not([data-theme="light"]):not(:has(#fps-03-dark:checked)) .fps-03__mpanel p {
    color: hsl(var(--h) 25% 72%);
  }
}

@supports not selector(:has(*)) {
  .fps-03__theme {
    display: none;
  }
}

@media (max-height: 40rem) {
  .fps-03 {
    --h-scroller: clamp(7.5rem,24vh,17rem);
  }

  .fps-03__scroller {
    scroll-snap-type: y proximity;
  }

  .fps-03__panel {
    padding-block: 1.25rem 3rem;
  }

  .fps-03__stage {
    gap: .7rem;
  }
}

@media (prefers-reduced-motion: reduce) {
  .fps-03__scroller,
    .fps-03__mscroll {
    scroll-snap-type: none;
    scroll-behavior: auto;
  }

  .fps-03__mscroll--stop .fps-03__mpanel {
    scroll-snap-stop: normal;
  }

  .fps-03 *,
    .fps-03 *::before,
    .fps-03 *::after {
    animation: none !important;
    transition: none !important;
  }
}

@media (forced-colors: active) {
  .fps-03__panel,
    .fps-03__box,
    .fps-03__mscroll,
    .fps-03__mpanel,
    .fps-03__theme,
    .fps-03__tag {
    border-color: CanvasText;
    box-shadow: none;
    background: Canvas;
    color: CanvasText;
    -webkit-backdrop-filter: none;
    backdrop-filter: none;
  }
}
```

How this works

One declaration, and it goes on the snap target, not the container. That is the part people get wrong:

/* on each PANEL — not on the scroller */
.fps-03__panel{
  min-block-size:100dvh;
  scroll-snap-align:start;
  scroll-snap-stop:always;   /* the scroll may not pass this point */
}

What normal actually does. The default value lets a single fling travel as far as its momentum carries it and only then settle on the nearest snap point. On a trackpad or a phone that routinely means two or three panels skipped — and on a narrative page (product tour, onboarding, case study) a skipped panel is a skipped argument.

What always guarantees. The scroll is not allowed to pass a snap point with scroll-snap-stop:always: each gesture advances exactly one panel, no matter how hard it is thrown. That gives you the one-section-per-swipe feel of a full-page library with zero JavaScript and zero wheel interception.

@supports not (scroll-snap-stop:always){
  .fps-03__note{ display:block; }   /* tell the reader it degrades to normal */
}

Where it hurts. On a long sequence, always means the user must make one gesture per panel — twelve panels is twelve flicks, which reads as friction and is genuinely hostile on a page people want to skim. Reach for it on short, high-intent sequences (three to six panels) and leave normal on anything longer. It also amplifies the keyboard trap risk covered in demo 12, so pair it with proximity snapping under prefers-reduced-motion.

Reading the demo. Both scrollers hold five numbered panels. Throw each one as hard as you can: the left counter jumps 01 → 03 → 05, the right one always lands on the next number in sequence.

Make it yours

  • Apply always selectively: put it only on the two or three panels that must not be missed (a pricing panel, a consent panel) and leave the rest normal.
  • Combine with scroll-snap-align:center for a card carousel where every card must be inspected.
  • Reset it under reduced-motion — scroll-snap-stop:normal plus scroll-snap-type:none — so users who disable animation are never forced through a queue.
  • Change the panel palette by editing the --h hue on each panel; every panel derives its fill from one hue token.
  • Increase the mini-scroller height by editing --h-scroller; both columns read from the same value so the comparison stays fair.
  • For horizontal carousels the property is identical — set it on the slides and keep scroll-snap-type:x mandatory on the rail (see demo 11).

Gotchas — read before shipping

  • scroll-snap-stop belongs on the snap children. Setting it on the scroll container does nothing at all — the most common reason people think it is unsupported.
  • It has no effect unless the element is already a snap target with a scroll-snap-align other than none.
  • always on a long page is a usability regression: every panel costs one gesture, so skimmers cannot skim. Keep sequences short.
  • Programmatic scrolls (scrollTo, anchor jumps) are not blocked by always — it constrains user gestures only.
  • Firefox shipped it late (132); older Firefox silently behaves like normal, so treat it as an enhancement and never rely on it for correctness.
  • Combined with mandatory snapping and internally scrollable panels, always can strand keyboard users. Test Tab and Page Down before shipping.

Browser support

ChromeSafariFirefoxEdge
114+17.5+121+114+

Floor set by :has(), oklch(), color-mix(), backdrop-filter, text-wrap as this demo is written. The core technique itself goes back further — Chrome 75+.

scroll-snap-stop: Chrome 75+, Safari 15+, Firefox 132+. Behind @supports (scroll-snap-stop:always) with a visible notice as the fallback; where unsupported the scroller behaves exactly like scroll-snap-stop:normal, so nothing breaks.

Techniques used in this demo

Search CodeFronts

Loading…