Scroll Snapping

Auto-alignment after scroll
Demonstrates the built-in snap feature. When scrolling stops, the view automatically smooth-scrolls to align with the nearest item. Useful for carousels, page-by-page navigation, or pickers.
Note: Snapping is disabled if the item is larger than the viewport.

How to build a feature like this

Snapping makes a list settle on item boundaries instead of wherever the user happens to stop - the behaviour behind paged lists, carousels, and step pickers. With VirtualScroll it is a single prop: set snap to one of the SnapMode values and, once scrolling stops, the engine aligns the view to an item by issuing an animated (smooth) scrollToIndex toward a target it derives from each row's size and offset. Because the alignment runs only after the scroll ends, fast flings keep their natural momentum and then ease onto the nearest boundary rather than being interrupted mid-gesture. Two constraints shape how you use it: an item that is larger than the viewport disables snapping for that axis (to avoid jarring long jumps), and snapping is skipped for programmatic scrolls so your own navigation never fights it.

1. Enable snap on a list whose items are “pages”

Start from any uniform or dynamic list and give the rows a size that suits one logical step - often one row roughly fills the viewport so each snap lands on a single page or item. Bind snap to a SnapMode; you can pass a fixed value or a reactive ref if the mode should change at runtime (for example a settings control). The simplest useful configuration snaps each row to the viewport start, but which mode fits depends on the content, so start with 'next' for paged stepping and read on for the other alignments.

The examples also draw the built-in virtual scrollbar (boolean virtual-scrollbar) on the list. Besides consistent cross-browser styling it is a performance improvement: the overlay bar is driven by the engine's own scroll math, so its rendering cost stays flat no matter how long the list grows.

<script setup lang="ts">
import type { SnapMode } from '@pdanpdan/virtual-scroll';
import { VirtualScroll } from '@pdanpdan/virtual-scroll';
import '@pdanpdan/virtual-scroll/style.css';
import { ref } from 'vue';

// Pick one: false (off), true / 'auto', 'start', 'center', 'end', 'next'.
const snap = ref<SnapMode>('next');
const pages = Array.from({ length: 60 }, (_, i) => ({
  id: i,
  label: `Page ${ i + 1 }`,
  color: `hsl(${ (i * 47) % 360 } 70% 80%)`,
}));
</script>

<template>
  <!-- Page-sized rows (300px) in a 480px viewport: each fling settles with one
       full page in view. buffer-* counts rows, so keep the overscan small. -->
  <VirtualScroll
    virtual-scrollbar
    class="pages"
    :items="pages"
    :item-size="300"
    :snap="snap"
    :buffer-before="1"
    :buffer-after="1"
    aria-label="Paged list"
  >
    <template #item="{ item, index }">
      <div class="page" :style="{ backgroundColor: item.color }">
        <strong>Page {{ index + 1 }}</strong>
        <span>{{ item.label }} - release to snap</span>
      </div>
    </template>
  </VirtualScroll>
</template>

<style scoped>
.pages {
  height: 480px;
}
.page {
  height: 100%;
  box-sizing: border-box;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 0.5rem;
  color: #333;
}
</style>

2. Choose the alignment that matches the content

The SnapMode union is false | true | 'auto' | 'start' | 'center' | 'end' | 'next', and true is an alias for 'auto'. They differ in which item they target and where they put it:

  • 'start' - aligns the first visible item to the viewport's start, snapping to the next item when the current one is less than ~50% visible; suits top-anchored reading lists.
  • 'end' - mirrors 'start' at the viewport end (last visible item ≥ ~50% else the previous one); suits bottom-anchored feeds or chat.
  • 'center' - brings the item that crosses the viewport center to the center; suits carousels and galleries where you want neighbours peeking at the edges.
  • 'next' - snaps to the nearest snap position in the direction of travel, so repeated scrolls advance one item; a good default for paged stepping.
  • 'auto' - direction-aware: behaves like 'end' while scrolling back toward the start and like 'start' while scrolling toward the end, so large free-scroll content still lands aligned.

Because snap is just a prop, switching between these (or to false) at runtime reconfigures the behaviour with no other change.

3. Keep items smaller than the viewport

Snapping is meaningful only when the viewport can hold the item, so the engine silently declines to snap an axis while the target item is taller (or wider) than the viewport; it deliberately avoids dragging a viewport-sized jump. If your item size is not a hard constant - a responsive row, a user-controlled slider - mirror that rule in the UI: compare the item size against the measured viewport from the last @scroll event (the emitted ScrollDetails exposes viewportSize) and show a hint that snapping is inactive. This mirrors what the engine itself decides, so the UI never promises a snap the engine will not perform.

import { computed, ref } from 'vue';
import type { ScrollDetails, SnapMode } from '@pdanpdan/virtual-scroll';

const itemSize = ref(300);
const snap = ref<SnapMode>('next');
const details = ref<ScrollDetails | null>(null);

// The engine quietly skips snapping while the target item is taller than the
// viewport. Mirror that rule in the UI (e.g. a warning badge) by comparing the
// item size with the viewport height from the last @scroll event.
const itemTooLarge = computed(
  () => details.value !== null && itemSize.value > details.value.viewportSize.height,
);

4. Know when it fires and how the motion feels

The snap extension listens for scroll end, not every pixel, and it ignores programmatic scrolling (the engine's own scrollToIndex/scrollToOffset and any calls from your code): only user-driven scrolls trigger a realignment. Wheel and touch motion is not throttled or snapped mid-flight - you scroll naturally, and once you release, the corrective movement is a smooth animation onto the resolved boundary, so the last few dozen pixels ease rather than jump. Set snap to false (the default) to restore free behaviour, or switch modes live to compare alignments.

Item Height300px
1
Page 1
Scroll, then release to snap
2
Page 2
Scroll, then release to snap
3
Page 3
Scroll, then release to snap
4
Page 4
Scroll, then release to snap
5
Page 5
Scroll, then release to snap
  • Scroll Status
  • Direction
    vertical
  • Current Item #
    -
  • Rendered Range #
    0:0
  • Total Size (px)
    0w ×0h
  • Viewport Size (px)
    0w ×0h
  • Scroll Offset (px)
    0x ×0y
  • Controls