Skip to content

Virtual scroller: 1,000,000 rows on Lenis

A virtualized list where scrolling itself is virtual: a customized Lenis drives the position over translateY instead of native scroll, so momentum, touch feel and wheel behavior are owned by code — while the DOM holds only the visible window between two spacer divs. Heights are estimated until rows are seen, captured one-shot as rows enter and leave, and every operation stays O(window): nothing ever costs O(total items).

The class is a single ivue Reactive() unit — template refs, prop refs, scroll state, the windowing math and the Lenis lifecycle all live on one instance, constructed in setup() and torn down by the component scope.

Using it

vue
<script setup lang="ts">
import VirtualScroller from './virtual-scroller/VirtualScroller.vue'
import type { BaseItem } from './virtual-scroller/VirtualScroller.types'

const items = ref<BaseItem[]>(loadRows()) // any size — 1M is routine
</script>

<template>
  <VirtualScroller v-model="items" :assumed-height="56" :padding-quantity="10">
    <template #item="{ item }">
      <article>{{ item.body }}</article>
    </template>
  </VirtualScroller>
</template>

The source

The exact files running above, tabbed — the class alone is ~1,200 lines, so each block scrolls inside itself. The demo wrapper is docs code; everything else is the production component.

ts
import type { Ref } from 'vue';
import {
  computed,
  nextTick,
  onBeforeUnmount,
  onMounted,
  ref,
  toRaw,
  toRef,
  watch
} from 'vue';

import { useElementSize, useResizeObserver } from '@vueuse/core';
import { Reactive, type ReactiveInstance } from '../../ivue';
import { Lenis } from '../../lenis/lenis';
import type { BaseItem } from './VirtualScroller.types';
import type {
  ItemContext,
  ItemsChangeEmitArgs,
  VirtualScrollerEmits,
  VirtualScrollerProps
} from './VirtualScroller.vue';

/**
 * Virtualized scroller (ivue v2 `Reactive` class).
 *
 * Scrolling is driven by a customized Lenis over translateY — not native
 * scroll — and the feel is hand-tuned. The autoplay SPEED (CREEP_MS_PER_PX,
 * the original 1px/150ms cadence) and every Lenis option are load-bearing;
 * treat them as constants. The creep DELIVERY is a per-frame integrator
 * (see creepStep) — do not go back to timer ticks smoothed by CSS
 * transitions; that produced a velocity sawtooth felt as judder on low-DPI
 * screens.
 *
 * POSITION MODEL: rendered items are NORMAL-FLOW block elements between two
 * spacer divs — the browser stacks the window at real heights for free; no
 * per-item `top` is computed or maintained. Estimates only decide the two
 * spacer heights and the scrollTop↔index mapping: an item's estimated top
 * is the prefix sum `P(i) = Σ (measuredHeights[j] ?? assumedHeight)` for
 * `j < i`, never materialized as an array — it is evaluated lazily by
 * walking a movable cursor `(index, offset)` kept exactly equal to
 * `P(index)` under the current height map, plus O(1) aggregates
 * (`measuredSum`/`measuredCount`) for the total content height. Heights are
 * captured ONE-SHOT (item mount + final height at item unmount — see
 * VirtualScrollerItem.vue), not continuously observed: a height sync costs
 * O(1), resolving the visible window costs O(items scrolled since last
 * frame), and nothing ever costs O(total item count) — which is what made
 * 100k-item posts jitter when the prefix sum was a real array rebuilt on
 * every (debounced) ResizeObserver burst.
 */
class $VirtualScroller<T extends BaseItem> {
  constructor (
    public props: VirtualScrollerProps<T>,
    public emit: VirtualScrollerEmits
  ) {
    this.elementSize = useElementSize(this.scrollElement);
    this.outerElementSize = useElementSize(this.scrollElement, undefined, {
      box: 'border-box'
    });

    // ONE observer per scroller — on the items wrapper, whose height only
    // changes when a rendered item's real height does (spacers are siblings).
    // The callback re-reads just the rendered window (O(window), never
    // O(total)). This is what keeps rendered-item heights truthful for the
    // scroll clamps and index→position math: slot content hydrates a tick
    // after item mount (mount-time capture reads the pre-hydration height),
    // fonts/images settle later still — and none of that re-fires per-item
    // observers anymore.
    useResizeObserver(this.itemsWrapperElement, () =>
      this.remeasureRenderedItems()
    );

    this.updatePositionsImmediately();

    // Structural changes (splice/filter/wholesale replace) shift what every
    // index means — re-derive aggregates/cursor from the current map. The
    // old model self-healed the same way via its full array rebuild.
    watch(
      () => this.items.value.length,
      () => this.updatePositionsImmediately()
    );

    if (this.autoPlay.value) this.startAutoPlay(this.props.autoPlayDelay);

    onMounted(() => {
      if (!this.scrollElement.value || !this.scrollElementInner.value) return;

      this.lenis = new Lenis({
        wrapper: this.scrollElement.value,
        content: this.scrollElementInner.value,
        syncTouch: true, // Sync touch events
        smoothWheel: true,
        autoRaf: false, // we drive it ourselves
        syncTouchLerp: 0.1,
        touchInertiaMultiplier: 30,
        touchMultiplier: 1.3 // Sensitivity of touch scrolling
      });
      this.lenis.on('virtual-scroll', this.onVirtualScroll);

      // The DOM is much shorter than the virtual content (content-sized
      // layer + capped tail — see trailingSpacerPx), so lenis takes its
      // wheel-clamp limit from the COMPUTED height — same box as
      // setScrollPosition's own bottom clamp. A pull callback, not a
      // watcher: lenis reads it at clamp time, the computed caches, and it
      // can never be stale.
      this.lenis.virtualLimit = () =>
        Math.max(
          0,
          this.scrollHeight.value -
          (this.scrollElement.value?.offsetHeight ?? 0)
        );
    });

    onBeforeUnmount(() => {
      cancelAnimationFrame(this.frame);
      cancelAnimationFrame(this.creepFrame);
      this.stopScrollToIndexReapply?.();
      this.lenis?.stop();
      this.lenis?.destroy();
    });
  }

  /* Template refs */

  get scrollElement() {
    return ref(null) as Ref<HTMLElement | null>;
  }

  get scrollElementInner() {
    return ref(null) as Ref<HTMLElement | null>;
  }

  /** The div wrapping the rendered items (between the two spacers). */
  get itemsWrapperElement() {
    return ref(null) as Ref<HTMLElement | null>;
  }

  /* Props as refs */

  get items() {
    return toRef(this.props, 'modelValue');
  }

  get assumedHeight() {
    return toRef(this.props, 'assumedHeight');
  }

  get paddingQuantity() {
    return toRef(this.props, 'paddingQuantity');
  }

  get autoPlay() {
    return toRef(this.props, 'autoPlay');
  }

  /* Container size */

  private elementSize: ReturnType<typeof useElementSize>;
  private outerElementSize: ReturnType<typeof useElementSize>;

  get containerHeight() {
    return this.elementSize.height;
  }

  /**
   * Border-box container height — the same box setScrollPosition's bottom
   * clamp measures (offsetHeight). Seek math must use THIS, not the
   * content-box containerHeight: the padding difference is invisible on a
   * huge post but parks the knob 10-15% short of the end on a small one.
   */
  get containerOuterHeight() {
    return this.outerElementSize.height;
  }

  /* Scroll state */

  /** Absolute (unsigned) scroll offset within the content. */
  get scrollPosition() {
    return ref<string | number>(0);
  }

  get scrollDirection() {
    return ref('down');
  }

  /** Reactive autoplay state — true while the reading creep is armed.
   *  Consumers bind buttons to it; a user scroll UP flips it off. */
  get isAutoPlaying() {
    return ref(false);
  }

  /** Measured pixel heights by item index (unmeasured fall back to assumedHeight). */
  get measuredHeights() {
    return ref<Record<number, number>>({});
  }

  /**
   * Bumped whenever item geometry may have changed (height sync, structural
   * repair). The reactive invalidation signal for visibleItems/scrollHeight/
   * getIndexPosition — replaces the old wholesale `positions` array
   * replacement. Bumps are O(1) and evaluations are O(window), so no
   * debounce is needed anywhere anymore.
   */
  private get geometryVersion() {
    return ref(0);
  }

  private bumpGeometryVersion() {
    this.geometryVersion.value++;
  }

  /**
   * Movable prefix-sum cursor. INVARIANT: `offset === P(index)` (sum of
   * measured-or-assumed heights of every item before `index`) under the
   * current measuredHeights/assumedHeight/items — maintained O(1) in
   * syncItemHeight and re-derived from scratch in updatePositionsImmediately.
   * Deliberately a plain non-reactive field (like visibleItemsSnapshot):
   * it is a cache; reactivity flows through geometryVersion.
   */
  private cursor = { index: 0, offset: 0 };

  /** Σ of all values in measuredHeights — non-reactive, see cursor. */
  private measuredSum = 0;

  /** Number of keys in measuredHeights — non-reactive, see cursor. */
  private measuredCount = 0;

  /** Post-calibration per-item estimate (frozen once) — see below. */
  private calibratedAssumed: number | null = null;

  /**
   * The height assumed for unmeasured items. Starts as the assumedHeight
   * prop; once enough real measurements exist it calibrates to the post's
   * true average (once, frozen). The prop's fixed value is biased low for
   * prose (50 vs ~130 real), which warps every estimate-derived quantity —
   * scrollHeight, the seek mapping, the knob — by 2-3x until items are
   * measured. Reads are plain (non-reactive); geometryVersion bumps cover
   * invalidation at the calibration moment.
   */
  private get estimatedItemHeight() {
    return this.calibratedAssumed ?? this.assumedHeight.value;
  }

  /**
   * One-time estimate calibration. Runs only while the reader is near the
   * top: there the scrollTop→content mapping goes through fully-measured
   * items, so swapping the assumption for the tail cannot move anything
   * visible — the change lands entirely in the trailing spacer.
   */
  private maybeCalibrateEstimate() {
    if (this.calibratedAssumed !== null) return;
    const length = toRaw(this.items.value).length;
    if (this.measuredCount < 20 || this.measuredCount >= length) return;
    const scrollPosition = this.scrollPosition.value;
    const scrollTop =
      typeof scrollPosition === 'number'
        ? scrollPosition
        : parseFloat(scrollPosition) || 0;
    if (scrollTop > this.containerHeight.value) return;
    this.calibratedAssumed = this.measuredSum / this.measuredCount;
    this.updatePositionsImmediately();
  }

  /** The currently rendered window, including padding. */
  get visibleIndex() {
    return ref({
      start: 0,
      end: 0
    });
  }

  /**
   * Spacer heights around the rendered window — the whole leading/trailing
   * content reduced to two numbers. Written by visibleItems on every
   * evaluation (same mutate-inside-computed pattern as visibleIndex).
   */
  private get leadingSpacerHeight() {
    return ref(0);
  }

  private get trailingSpacerHeight() {
    return ref(0);
  }

  get leadingSpacerPx() {
    return (
      $VirtualScroller.snapForRender(
        Math.max(0, this.leadingSpacerHeight.value - this.renderBias.value)
      ) + 'px'
    );
  }

  /** How much tail actually gets RENDERED below the window — a safety
   *  margin of a few viewports, not the whole remaining post. The layer
   *  (the inner element) is content-sized; rendering the true tail made it
   *  ~10M px tall on a 100k-item post, and layers that size carry visible
   *  compositor heaviness (confirmed by feel test: capping the layer was
   *  the difference between "slight chop" and "fully smooth"). Nothing
   *  below the fold reads the tail — scroll range comes from the computed
   *  height via lenis.virtualLimit. */
  private static readonly TRAILING_SPACER_RENDER_CAP = 2048;

  get trailingSpacerPx() {
    return (
      $VirtualScroller.snapForRender(
        Math.min(
          $VirtualScroller.TRAILING_SPACER_RENDER_CAP,
          this.trailingSpacerHeight.value
        )
      ) + 'px'
    );
  }

  /**
   * SCROLL-ORIGIN REBASING. GPU compositing is single precision: past
   * ~2^23 px even integer positions lose sub-pixel raster placement, so a
   * reader deep in a 100k-item post stutters no matter how exact the CSS
   * values are — the content itself must live at small coordinates. The
   * bias (a multiple of 65,536, updated as the scroll crosses chunks) is
   * subtracted from BOTH the leading spacer and the applied translate in
   * the same frame: their difference — everything visible — is unchanged,
   * but the rendered numbers stay below ~131k px at any reading depth,
   * the same regime a normal-sized post renders in. All scroll MATH stays
   * absolute; only the two render outputs are shifted. A ref, not a plain
   * field: the spacer template binding must re-render on rebase.
   */
  private get renderBias() {
    return ref(0);
  }

  private static readonly RENDER_BIAS_CHUNK = 65536;

  private updateRenderBias(scroll: number) {
    const chunk = $VirtualScroller.RENDER_BIAS_CHUNK;
    const bias = Math.max(0, (Math.floor(scroll / chunk) - 1) * chunk);
    if (bias !== this.renderBias.value) {
      this.renderBias.value = bias;
      if (this.lenis) this.lenis.renderOffset = bias;
    }
  }

  /**
   * Device-pixel snap for LANDINGS (spacers, seeks/jumps): a resting
   * position on the grid keeps text crisp. The snap policy is "motion is
   * fractional, landings snap" — continuous MOTION paths (the wheel lerp in
   * lenis.setScroll, the reading creep via snapRender=false) deliberately
   * bypass this: snapped sub-device-pixel-per-frame motion degenerates into
   * whole-pixel ticks at visible rates, while fractional translateY is
   * filtered by the compositor into an apparent glide. Safe at any depth —
   * renderBias keeps rendered offsets ≤ ~131k px, where f32 resolves both
   * integers and fractions.
   */
  private static snapForRender(value: number) {
    const dpr = window.devicePixelRatio || 1;
    return Math.round(value * dpr) / dpr;
  }

  get scrollHeight() {
    // THIN computed — the caching shell only; the logic stays named in a
    // directly testable method on the prototype.
    return computed(() => this.computeScrollHeight());
  }

  private computeScrollHeight(): number {
    const len = this.items.value.length;

    if (len === 0) return 0;

    /** Account for padding top and bottom of virtual scroller. */
    let paddingTop = 0;
    let paddingBottom = 0;
    if (this.scrollElement.value) {
      const computedStyle = window.getComputedStyle(
        this.scrollElement.value,
        null
      );
      paddingTop = parseInt(computedStyle.getPropertyValue('padding-top'));
      paddingBottom = parseInt(
        computedStyle.getPropertyValue('padding-bottom')
      );
    }

    // O(1) total: P(len) = measured sum + assumed estimate for the rest.
    this.geometryVersion.value;
    return (
      this.measuredSum +
      Math.max(0, len - this.measuredCount) * this.estimatedItemHeight +
      paddingTop +
      paddingBottom
    );
  }

  private get halfPaddingQuantity() {
    return Math.ceil(this.paddingQuantity.value / 2);
  }

  /**
   * Previous visibleItems result — returned again when the window is
   * unchanged so the computed's equality check stops propagation.
   */
  private visibleItemsSnapshot: ItemContext<T>[] = [];

  /**
   * The window of items currently rendered. Hot path: re-evaluates on every
   * scroll tick, so it must stay O(window + scroll delta) — never O(total).
   *
   * - Window resolution walks the prefix-sum cursor from wherever it last
   *   was to the current scrollTop — plain object reads on the RAW height
   *   map, no proxy traps. Geometry changes are tracked via geometryVersion.
   * - Items are read through the REACTIVE array on purpose: the item
   *   proxies must stay live for editing, and per-index tracking is what
   *   invalidates the window on splice/reorder.
   * - COMPARE-FIRST: the window is checked against the previous snapshot
   *   before anything is built. On a stable window (the 60–120Hz autoplay /
   *   lenis path) the previous ARRAY INSTANCE is returned with ZERO
   *   allocations, and the computed's equality check stops propagation —
   *   the v-for never re-renders. Only a genuinely shifted window builds a
   *   new array (plain for-loop, no slice/map).
   */
  get visibleItems() {
    // THIN computed — see computeScrollHeight's note.
    return computed(() => this.computeVisibleItems());
  }

  private computeVisibleItems(): ItemContext<T>[] {
    this.geometryVersion.value;
    const items = this.items.value;
    const len = items.length;
    const measured = toRaw(this.measuredHeights.value);
    const assumed = this.estimatedItemHeight;
    const scrollPosition = this.scrollPosition.value;
    const scrollTop =
      typeof scrollPosition === 'number'
        ? scrollPosition
        : parseFloat(scrollPosition);

    // Walk the cursor to the last item whose top is at/above scrollTop —
    // same semantics the binary search over the dense array had.
    const cursor = this.cursor;
    let start = Math.min(cursor.index, Math.max(0, len - 1));
    let startOffset = cursor.offset;
    for (let i = cursor.index; i > start; i--) {
      // Cursor beyond a shrunk list (pre-repair) — walk it back in.
      startOffset -= measured[i - 1] ?? assumed;
    }
    if (len > 0) {
      let h;
      while (
        start < len - 1 &&
        startOffset + (h = measured[start] ?? assumed) <= scrollTop
      ) {
        startOffset += h;
        start++;
      }
      while (start > 0 && startOffset > scrollTop) {
        start--;
        startOffset -= measured[start] ?? assumed;
      }
      cursor.index = start;
      cursor.offset = startOffset;
    }

    // Walk forward until the window covers the container height.
    let end = start;
    let endOffset = startOffset;
    const bottom = startOffset + this.containerHeight.value;
    while (end < len && endOffset < bottom) {
      endOffset += measured[end] ?? assumed;
      end++;
    }

    const padding = this.halfPaddingQuantity;
    const paddedStart = Math.max(0, start - padding);
    end += padding + 1;

    if (
      this.visibleIndex.value.start !== paddedStart ||
      this.visibleIndex.value.end !== end
    ) {
      this.visibleIndex.value.start = paddedStart;
      this.visibleIndex.value.end = end;
      nextTick(() => this.onItemsChanged({ start: paddedStart, end }));
    }

    // Clamp like Array.slice did — items and geometry can briefly
    // disagree between a splice and the structural repair.
    const count = Math.min(end, len);
    const length = Math.max(0, count - paddedStart);

    // Estimated top of the first rendered item = the leading spacer.
    let paddedStartOffset = startOffset;
    for (let i = start - 1; i >= paddedStart; i--) {
      paddedStartOffset -= measured[i] ?? assumed;
    }
    if (paddedStart === 0 || paddedStartOffset < 0) paddedStartOffset = 0;

    // Trailing spacer: everything after the window. P(len) equals the
    // aggregate total by the cursor invariant, so this is exactly 0 when
    // the window reaches the last item (clamped for float drift).
    let afterWindowOffset = paddedStartOffset;
    for (let i = paddedStart; i < count; i++) {
      afterWindowOffset += measured[i] ?? assumed;
    }
    const total =
      this.measuredSum + Math.max(0, len - this.measuredCount) * assumed;
    // Spacers must update even when the window itself is unchanged
    // (e.g. a height correction above the window moved only the lead).
    this.leadingSpacerHeight.value = paddedStartOffset;
    this.trailingSpacerHeight.value =
      count >= len ? 0 : Math.max(0, total - afterWindowOffset);

    const prev = this.visibleItemsSnapshot;
    if (prev.length === length) {
      let unchanged = true;
      for (let i = 0; i < length; i++) {
        const context = prev[i];
        const index = paddedStart + i;
        const item = items[index];
        if (
          context.item !== item ||
          // item.id is read here to keep dependency parity with the build
          // path, so an id change still invalidates a stable window.
          context.id !== item.id ||
          context.index !== index
        ) {
          unchanged = false;
          break;
        }
      }
      if (unchanged) return prev;
    }

    const next: ItemContext<T>[] = new Array(length);
    for (let i = 0; i < length; i++) {
      const index = paddedStart + i;
      const item = items[index];
      next[i] = {
        item: item,
        id: item.id,
        index: index
      };
    }
    return (this.visibleItemsSnapshot = next);
  }

  private onItemsChanged(args: ItemsChangeEmitArgs) {
    this.emit('itemsChanged', args);
  }

  /* Positions */

  /**
   * Structural repair: re-derive the aggregates and the cursor offset from
   * the current height map, prune measurements of items that no longer
   * exist, and invalidate geometry immediately. O(#measured) over plain
   * values — it runs imperatively (never inside an effect), so nothing needs
   * tracking. Called after splices (by PostPlayer and the items-length
   * watch); the per-height-sync hot path never comes through here.
   */
  updatePositionsImmediately() {
    const measured = toRaw(this.measuredHeights.value);
    const assumed = this.estimatedItemHeight;
    const length = toRaw(this.items.value).length;

    const cursorIndex = Math.min(this.cursor.index, Math.max(0, length - 1));

    /** Remove the measurements of the items that no longer exist. (Same
     * contiguous-from-end prune the old rebuild did — farther stale keys
     * are kept unaggregated and, like before, resurrect if the list regrows
     * over them, until the rendered item re-measures.) */
    let beyondLastIndex = length;
    if (beyondLastIndex in measured) {
      while (measured[beyondLastIndex]) {
        delete this.measuredHeights.value[beyondLastIndex];
        beyondLastIndex++;
      }
    }

    let sum = 0;
    let count = 0;
    let sumBeforeCursor = 0;
    let countBeforeCursor = 0;
    for (const key in measured) {
      const index = +key;
      if (index >= length) continue;
      const height = measured[index];
      if (height === undefined) continue;
      sum += height;
      count++;
      if (index < cursorIndex) {
        sumBeforeCursor += height;
        countBeforeCursor++;
      }
    }
    this.measuredSum = sum;
    this.measuredCount = count;
    this.cursor.index = cursorIndex;
    this.cursor.offset =
      sumBeforeCursor + (cursorIndex - countBeforeCursor) * assumed;

    this.bumpGeometryVersion();
  }

  /**
   * Re-read the real heights of every rendered item in one pass —
   * O(window), driven by the single wrapper ResizeObserver. Reads happen
   * in one layout pass (no interleaved writes); only changed heights sync.
   */
  private remeasureRenderedItems() {
    const wrapper = this.itemsWrapperElement.value;
    if (!wrapper) return;
    const rendered = wrapper.querySelectorAll<HTMLElement>(
      '.virtual-scroller__item'
    );
    // Rects are in SCREEN px; the map must be in LAYOUT px. An ancestor
    // transform scale (the post card scales to fit the window) would
    // otherwise shrink every recorded height by the scale factor while the
    // flow renders at full layout height — the map diverges from the flow
    // and index-targeted jumps land short by exactly that drift. The
    // wrapper's rect-to-layout ratio is the scale; divide it out.
    const scale =
      wrapper.offsetHeight > 0
        ? wrapper.getBoundingClientRect().height / wrapper.offsetHeight
        : 1;
    const measured = toRaw(this.measuredHeights.value);
    let changed = false;
    const heights: [number, number][] = [];
    for (const el of rendered) {
      const row = el.getAttribute('aria-rowindex');
      if (row === null) continue;
      heights.push([
        +row - 1,
        el.getBoundingClientRect().height / (scale > 0 ? scale : 1)
      ]);
    }
    for (const [index, height] of heights) {
      if (measured[index] !== height) {
        this.syncItemHeight(index, height, false);
        changed = true;
      }
    }
    if (changed) {
      this.bumpGeometryVersion();
      this.maybeCalibrateEstimate();
    }
  }

  syncItemHeight(index: number, height: number, doUpdatePositions = true) {
    if (index < 0) return;
    if (index >= toRaw(this.items.value).length) {
      // Beyond the current list (mid-edit shift loops): keep the value for
      // neighbor reads, but out-of-range keys never count toward geometry —
      // exactly like the old rebuild, which only summed j < length.
      if (height == null) delete this.measuredHeights.value[index];
      else this.measuredHeights.value[index] = height;
      if (doUpdatePositions) this.bumpGeometryVersion();
      return;
    }
    const assumed = this.estimatedItemHeight;
    const previous = toRaw(this.measuredHeights.value)[index];
    // O(1) bookkeeping that keeps the aggregates and the cursor invariant
    // (`offset === P(index)`) exact — heights before the cursor shift it.
    if (height == null) {
      // Callers copy neighbor heights that may not exist — undefined means
      // "unmeasured": drop the entry so the item falls back to assumedHeight
      // (the old rebuild got this via its `?? assumed`).
      if (previous !== undefined) {
        this.measuredCount--;
        this.measuredSum -= previous;
        if (index < this.cursor.index) {
          this.cursor.offset += assumed - previous;
        }
        delete this.measuredHeights.value[index];
      }
      if (doUpdatePositions) this.bumpGeometryVersion();
      return;
    }
    if (previous === undefined) {
      this.measuredCount++;
      this.measuredSum += height;
    } else {
      this.measuredSum += height - previous;
    }
    if (index < this.cursor.index) {
      this.cursor.offset += height - (previous ?? assumed);
    }
    this.measuredHeights.value[index] = height;
    if (doUpdatePositions) this.bumpGeometryVersion();
  }

  /**
   * Top offset of item `index` — lazily-evaluated prefix sum, walked from
   * the cursor (or from 0 when that is closer). `undefined` outside the
   * current items range. Reactive: re-evaluates when geometry settles, so
   * `watch(() => scroller.getIndexPosition(i), …)` behaves like watching
   * the old `positions[i]`.
   */
  getIndexPosition(index: number): number | undefined {
    this.geometryVersion.value;
    if (index < 0 || index >= this.items.value.length) return undefined;

    const measured = toRaw(this.measuredHeights.value);
    const assumed = this.estimatedItemHeight;
    const cursor = this.cursor;
    let cursorIndex = cursor.index;
    let offset = cursor.offset;
    if (index < cursorIndex - index) {
      // Walking up from the top is shorter than walking back from the cursor.
      cursorIndex = 0;
      offset = 0;
    }
    while (cursorIndex < index) {
      offset += measured[cursorIndex] ?? assumed;
      cursorIndex++;
    }
    while (cursorIndex > index) {
      cursorIndex--;
      offset -= measured[cursorIndex] ?? assumed;
    }
    cursor.index = cursorIndex;
    cursor.offset = offset;
    return offset;
  }

  /**
   * Pixel offset for a 0..1 ratio in ITEM-INDEX space: `ratio × (len − 1)`
   * names an item plus a fraction scrolled within it. This is the seek
   * bar's contract — its hover preview promises item `ceil(scaled)`, the
   * first item fully readable below the landed viewport top, and that
   * identity is height-independent so it survives the estimate→real
   * refinement after landing.
   *
   * `endGapPx` keeps the NEXT item's top at least that many px below the
   * landed viewport top (never clamping above the floor item's own top): a
   * high in-item fraction otherwise parks the boundary a knife-edge few px
   * under the top edge, where autoplay's reading creep or a late height
   * wave cuts the promised item moments after landing. The seek settle
   * re-applies this same clamped map at refined heights, so the gap holds
   * once the real heights are in. Cost: the last `endGapPx` of each item
   * is a scrub dead-zone — invisible next to typical item heights.
   */
  getRatioPosition(ratio: number, endGapPx = 0): number | undefined {
    const len = this.items.value.length;
    if (len === 0) return undefined;
    const scaled = Math.min(1, Math.max(0, ratio)) * (len - 1);
    const index = Math.floor(scaled);
    const position = this.getAnchoredPosition(index, scaled - index);
    if (position === undefined || endGapPx <= 0) return position;
    const base = this.getIndexPosition(index);
    const next = this.getIndexPosition(index + 1);
    if (base === undefined || next === undefined) return position;
    return Math.min(position, Math.max(base, next - endGapPx));
  }

  /**
   * Pixel offset of a CONTENT ANCHOR: item `index` plus a 0..1 fraction
   * scrolled within it. The anchor names what the reader is looking at, so
   * re-applying it while heights settle keeps the CONTENT still (the
   * indicator adapts instead — the search-jump behavior).
   */
  getAnchoredPosition(index: number, fraction = 0): number | undefined {
    const base = this.getIndexPosition(index);
    if (base === undefined) return undefined;
    const height =
      toRaw(this.measuredHeights.value)[index] ?? this.estimatedItemHeight;
    return base + fraction * height;
  }

  /**
   * The inverse: which item (+ fraction within it) lives at a pixel offset.
   * Walked from the cursor — O(distance), cheap for seek-bar use.
   */
  getIndexAtPosition(
    offset: number
  ): { index: number; fraction: number } | undefined {
    this.geometryVersion.value;
    const len = this.items.value.length;
    if (len === 0) return undefined;
    const measured = toRaw(this.measuredHeights.value);
    const assumed = this.estimatedItemHeight;
    const cursor = this.cursor;
    let index = Math.min(cursor.index, len - 1);
    let top = cursor.offset;
    while (index > 0 && top > offset) {
      index--;
      top -= measured[index] ?? assumed;
    }
    let height = measured[index] ?? assumed;
    while (
      index < len - 1 &&
      top + (height = measured[index] ?? assumed) <= offset
    ) {
      top += height;
      index++;
    }
    height = measured[index] ?? assumed;
    cursor.index = index;
    cursor.offset = top;
    return {
      index,
      fraction:
        height > 0 ? Math.min(1, Math.max(0, (offset - top) / height)) : 0
    };
  }

  /* Scrolling */

  get preventScrollEvent() {
    return ref(false);
  }

  onScroll(e: Event) {
    // Prevents native scrolling on focus of contenteditable elements.
    if (this.preventScrollEvent.value) {
      e.preventDefault();
      this.scrollElement.value.scrollTop = 0;
    }
  }

  disableScrollEvent() {
    this.preventScrollEvent.value = true;
  }

  enableScrollEvent() {
    this.preventScrollEvent.value = false;
  }

  setScrollPosition(
    position: number,
    animate = true,
    translateY = true,
    /** The creep passes false: at sub-device-pixel speeds a snapped
     *  transform ticks whole pixels at a visible rate; fractional motion
     *  lets the compositor filter it into an apparent glide. Safe at any
     *  depth — renderBias keeps the effective offset small, where f32
     *  still resolves fractions. */
    snapRender = true
  ) {
    // A non-finite position would poison lenis.targetScroll and freeze the
    // scroller until remount (invalid transforms are silently ignored, so
    // nothing ever recovers). Refuse it.
    if (!Number.isFinite(position)) return;
    if (
      position > 0 ||
      this.scrollHeight.value < (this.scrollElement.value?.offsetHeight ?? 0)
    )
      position = 0;

    // Prevent scrolling down beyond last paragraph
    if (
      Math.abs(position) +
      (this.scrollElement.value?.offsetHeight ?? 0) +
      (this.scrollElement.value?.scrollTop ?? 0) >
      this.scrollHeight.value &&
      this.scrollHeight.value > (this.scrollElement.value?.offsetHeight ?? 0)
    ) {
      position = -(
        // Must be negative
        this.scrollHeight.value -
        (this.scrollElement.value?.offsetHeight ?? 0) -
        (this.scrollElement.value?.scrollTop ?? 0)
      );
    }

    const absolutePosition = Math.abs(position);

    this.updateRenderBias(absolutePosition);

    this.scrollPosition.value = absolutePosition;
    if (this.scrollElementInner.value) {
      if (!animate) {
        this.scrollElementInner.value.style.transitionDuration = '0s';
      } else {
        this.scrollElementInner.value.style.transitionDuration = '0.45s';
      }
    }

    if (position == 0 && this.scrollElement.value?.scrollTop) {
      this.scrollElement.value!.scrollTop = 0;
    }

    if (translateY && this.scrollElementInner.value) {
      // Rebased + snapped for GPU precision (see renderBias/snapForRender);
      // scrollPosition and lenis keep full precision for the scroll math.
      const rendered = position + this.renderBias.value;
      this.scrollElementInner.value!.style.transform =
        'translateY(' +
        (snapRender ? $VirtualScroller.snapForRender(rendered) : rendered) +
        'px)';
      // Programmatic jumps write the transform directly — lenis must ADOPT
      // the jump, not just be told about it. Adopting kills any in-flight
      // wheel animation (a running lerp holds its own captured target;
      // seeking mid-inertia otherwise loses the fight, dragged back toward
      // the stale wheel target) and syncs lenis's animated position (or the
      // first wheel input afterwards lerps from wherever lenis last
      // animated, possibly millions of px away: a few frames of catch-up
      // sweep). The wheel path (translateY false — lenis owns the transform
      // there) keeps its lerp untouched.
      this.lenis.adoptExternalScroll(absolutePosition);
    }

    this.lenis.targetScroll = absolutePosition;
  }

  resetScrollTop() {
    this.scrollElement.value.scrollTop = 0;
  }

  /** Stop handle for the latest scrollToIndex re-apply watcher (see below). */
  private stopScrollToIndexReapply: (() => void) | null = null;

  /**
   * @param topOffsetPx pushes the landing DOWN so the target sits this many
   * pixels below the viewport top — context above a jumped-to item (and
   * clear of any fade overlay at the reading area's top edge).
   * @param innerFraction 0..1 point WITHIN the item to align to (0 = its
   * top). A search match deep inside a paragraph taller than the viewport
   * would otherwise land below the fold — the item's height keeps refining
   * through the settle loop, so this converges onto the real text position.
   */
  scrollToIndex(
    index: number,
    afterCallback?: () => void,
    animate = true,
    topOffsetPx = 0,
    innerFraction = 0
  ) {
    const targetPosition = () => {
      const position = this.getIndexPosition(index);
      if (position === undefined) return undefined;
      const height =
        toRaw(this.measuredHeights.value)[index] ?? this.estimatedItemHeight;
      return Math.max(0, position + innerFraction * height - topOffsetPx);
    };

    const position = targetPosition();

    if (position === undefined || !this.scrollElement.value) return;

    this.resetScrollTop();

    this.setScrollPosition(-position, animate);

    const setScroll = () => {
      nextTick(() => {
        const position = targetPosition();
        if (position === undefined) return;
        this.setScrollPosition(-position, animate);
        nextTick(() => {
          afterCallback?.();
        });
      });
    };

    setScroll();

    // Converge onto the target: the first jump lands on an ESTIMATED
    // position; the fresh window then measures in waves (mount → slot
    // hydration → wrapper-observer correction), each shifting P(index).
    // Re-apply on every change and disarm only after the position has been
    // QUIET for a while — a fixed disarm timer loses the race against late
    // waves and leaves the reader a paragraph or two off the target. A new
    // seek supersedes this loop (a stale one would fire on the next
    // unrelated height change and yank the reader back), and the reader
    // taking over the scroll abandons it immediately.
    this.stopScrollToIndexReapply?.();
    let quietTimer: ReturnType<typeof setTimeout>;
    const stop = () => {
      clearTimeout(quietTimer);
      stopWatch();
      if (this.stopScrollToIndexReapply === stop) {
        this.stopScrollToIndexReapply = null;
      }
    };
    const stopWatch = watch(
      () => this.getIndexPosition(index),
      () => {
        if (this.lenis?.isScrolling) {
          stop();
          return;
        }
        setScroll();
        clearTimeout(quietTimer);
        quietTimer = setTimeout(stop, 600);
      }
    );
    quietTimer = setTimeout(stop, 600);
    this.stopScrollToIndexReapply = stop;
  }

  /* Autoplay (Lenis-driven) */

  lenis: Lenis | null = null;
  private frame: number;

  private virtualScrolling = false;
  private virtualScrollTimeout;
  private autoscrollTimeout;
  private autoRepeatTimeout;

  onVirtualScroll({ deltaY }) {
    // Scrolling UP is the reader taking over — autoplay stops outright
    // (the frame loop re-arms below for the manual scroll itself).
    // Scrolling DOWN is reading intent — autoplay re-arms by itself and
    // the settle chain below resumes the creep once the input rests.
    if (this.isAutoPlaying.value && deltaY < 0) {
      this.stopAutoPlay();
    } else if (!this.isAutoPlaying.value && deltaY > 0) {
      this.isAutoPlaying.value = true;
    }
    this.virtualScrolling = true;
    clearTimeout(this.virtualScrollTimeout);
    this.scrollElementInner.value.style.transitionDuration = '0s';
    this.scrollDirection.value = deltaY < 0 ? 'up' : 'down';
    if (!this.frame) {
      // Lenis's clock aged while its raf loop was parked (the creep runs
      // without it) — reset it or the first frame advances the whole gap
      // and the flick lands as an instant jump instead of the lerp.
      this.lenis.time = 0;
      this.frame = requestAnimationFrame(this.loop);
    }
    if (this.isAutoPlaying.value) {
      // input settles → the creep resumes; never re-arms when not playing
      clearTimeout(this.autoscrollTimeout);
      this.autoscrollTimeout = setTimeout(this.play, 3);
    }

    this.virtualScrollTimeout = setTimeout(() => {
      this.virtualScrolling = false;
    }, 3);
  }

  loop(now: number) {
    // Rebase BEFORE lenis writes this frame's transform: the transform and
    // the spacer (rendered by this frame's flush) must shift together.
    this.updateRenderBias(Math.abs(this.lenis.scroll ?? 0));
    this.lenis.raf(now); // keep Lenis in sync
    this.frame = requestAnimationFrame(this.loop);
    this.setScrollPosition(-this.lenis.targetScroll, false, false);
  }

  startAutoPlay(delay = 500, callback = () => {}) {
    this.isAutoPlaying.value = true;
    // A prior up-scroll leaves direction 'up', which gates the creep off —
    // pressing play IS the intent to read downward again.
    this.scrollDirection.value = 'down';
    if (this.lenis) this.lenis.time = 0;
    this.frame = requestAnimationFrame(this.loop);
    this.autoscrollTimeout = setTimeout(() => {
      this.play();
      callback();
    }, delay);
  }

  stopAutoPlay(callback = () => {}) {
    this.isAutoPlaying.value = false;
    cancelAnimationFrame(this.frame);
    this.frame = null;
    cancelAnimationFrame(this.creepFrame);
    this.creepFrame = null;
    this.lastCreepTs = null;
    clearTimeout(this.autoscrollTimeout);
    callback();
  }

  /** Reading-creep speed: ms of wall time per px of content — the original
   *  cadence (1px per 150ms tick ≈ 6.7px/s), now integrated per FRAME. */
  private static readonly CREEP_MS_PER_PX = 150;

  /** rAF handle + last frame timestamp of the creep integrator. */
  private creepFrame: number | null = null;
  private lastCreepTs: number | null = null;

  play() {
    if (this.virtualScrolling || this.lenis.isScrolling) {
      clearTimeout(this.autoscrollTimeout);

      return (this.autoscrollTimeout = setTimeout(this.play, 3));
    }

    clearTimeout(this.autoscrollTimeout);
    // The reader is at rest — lenis has nothing to animate, so its raf loop
    // can stop (the old timer creep cancelled it one tick later).
    cancelAnimationFrame(this.frame);
    this.frame = null;
    cancelAnimationFrame(this.creepFrame);
    this.lastCreepTs = null;
    this.creepFrame = requestAnimationFrame(this.creepStep);
  }

  /**
   * The reading creep, integrated per FRAME (speed × Δt, transform written
   * directly, no CSS transition, UNSNAPPED — see the write below). The
   * original delivery — a 150ms setTimeout writing +1px targets smoothed
   * by a re-targeted 0.45s ease transition — produced a permanent ~6.7Hz
   * velocity sawtooth plus timer jitter: irregularly-timed device-pixel
   * crossings, felt as judder on low-DPI screens. A snapped integrator was
   * tried next: metronome-regular but WHOLE-pixel ticks at 6.7Hz, still
   * read as chop on dpr-1. Constant-velocity fractional motion is the
   * remaining delivery: the compositor filters ~0.11px/frame into an
   * apparent glide (cost: slight text softness while creeping).
   */
  creepStep(ts: number) {
    this.creepFrame = null;
    if (this.virtualScrolling || this.lenis.isScrolling) {
      // Reader took over — hand back to play()'s defer loop, which resumes
      // the creep when the input settles.
      this.lastCreepTs = null;
      this.play();
      return;
    }
    if (this.scrollDirection.value !== 'down') {
      this.lastCreepTs = null;
      return;
    }

    // Δt clamped so a background-tab rAF suspension resumes as a slow
    // frame, not a content jump.
    const dt =
      this.lastCreepTs === null ? 16.7 : Math.min(50, ts - this.lastCreepTs);
    this.lastCreepTs = ts;
    this.lenis.targetScroll += dt / $VirtualScroller.CREEP_MS_PER_PX;

    const containerH = this.scrollElement.value?.offsetHeight ?? 0;
    const atEnd =
      this.lenis.actualScroll + containerH >= this.scrollHeight.value - 10;

    if (this.props.autoRepeat && atEnd) {
      // End reached: stop creeping and let the auto-repeat chain own the
      // resumption (reset to top after a pause, then play again).
      clearTimeout(this.autoRepeatTimeout);
      this.autoRepeatTimeout = setTimeout(() => {
        this.setScrollPosition(0, true, true);
        this.autoscrollTimeout = setTimeout(() => {
          if (this.scrollDirection.value === 'down') {
            this.play();
          }
        }, this.props.autoPlayDelay);
      }, 10000);
      return;
    }

    clearTimeout(this.autoRepeatTimeout);
    // Unsnapped on purpose: constant-velocity FRACTIONAL motion — the
    // compositor's filtering renders ~0.11px/frame as an apparent glide.
    // Snapped, the same speed ticks a whole device pixel every 150ms on
    // dpr-1 screens, which reads as chop.
    this.setScrollPosition(-this.lenis.targetScroll, false, true, false);
    if (atEnd) {
      // Nothing left to creep into (setScrollPosition clamps at the end);
      // the next wheel re-arms play via onVirtualScroll.
      this.lastCreepTs = null;
      return;
    }
    this.creepFrame = requestAnimationFrame(this.creepStep);
  }

  /* Drag and Drop */

  private startIndex = 0;

  onStart(evt: any) {
    this.startIndex = evt.item.__draggable_context.element.index;
  }

  onDrop(evt: any) {
    const dropIndex =
      evt.target
        .closest('.virtual-scroller__item')
        .getAttribute('aria-rowindex') - 1;
    this.emit('drop', this.startIndex, dropIndex);
  }

  onMove(evt: any, originalEvent: any) {
    this.emit('move', evt);
    return true; // — keep default insertion point based on the direction
  }
}

/**
 * Standard namespace pattern, generic adaptation. `Reactive()` returns the
 * SAME constructor (identity preservation), but its return TYPE
 * (ReactiveClass<C>) cannot carry <T> — TS has no higher-kinded types — so
 * `Class` is cast back to the raw constructor type to keep
 * `new VirtualScroller.Class<T>()` fully generic. For the same reason
 * `typeof Class.Instance` cannot exist per-T; `Instance<T>` applies
 * `ReactiveInstance` explicitly instead.
 */
export namespace VirtualScroller {
  export const $Class = $VirtualScroller;
  export let Class = Reactive(
    $VirtualScroller
  ) as unknown as typeof $VirtualScroller;
  export type Instance<T extends BaseItem> = ReactiveInstance<
    $VirtualScroller<T>
  >;
}

export type VirtualScrollerReturn<T extends BaseItem> = $VirtualScroller<T>;
vue
<script lang="ts">
import type { ShallowUnwrapRef } from 'vue';

import type { VirtualScrollerReturn } from './VirtualScroller';
import { VirtualScroller } from './VirtualScroller';
import type { BaseItem } from './VirtualScroller.types';
import VirtualScrollerItem from './VirtualScrollerItem.vue';

export type VirtualScrollerExposed<T extends BaseItem> =
  VirtualScroller.Instance<T>;
/**
 * What consumers hold through a template ref: Vue's expose surface unwraps
 * refs on read and redirects ref writes into .value (proxyRefs semantics).
 * Instance (ReactiveInstance) is load-bearing here: it strips the readonly
 * that TS puts on get-only accessors, so writes like
 * `scroller.scrollDirection = 'down'` typecheck as they behave.
 */
export type VirtualScrollerExposedUnwrapped<T extends BaseItem> =
  ShallowUnwrapRef<VirtualScroller.Instance<T>>;

export interface VirtualScrollerEmits {
  (e: 'itemsChanged', args: ItemsChangeEmitArgs): void;
  (e: 'drop', startIndex: number, dropIndex: number): void;
  (e: 'move', evt: any): void;
}

export interface ItemsChangeEmitArgs {
  start: number;
  end: number;
}

export interface VirtualScrollerSlots<T extends BaseItem> {
  item: (scope: ItemContext<T>) => any;
}

export interface ItemContext<T extends BaseItem> {
  item: T;
  id: string;
  index: number;
}

export interface VirtualScrollerProps<T extends BaseItem> {
  modelValue: T[];
  autoPlay?: boolean;
  autoPlayDelay?: number;
  autoRepeat?: boolean;
  assumedHeight: number;
  paddingQuantity: number;
  /** Accepted for API compatibility; the docs build renders the plain branch. */
  draggable?: boolean;
  dragHandleSelector?: string;
  dragClass?: string;
  dragGhostClass?: string;
  dragChosenClass?: string;
}
</script>
<script lang="ts" setup generic="T extends BaseItem">
const props = withDefaults(defineProps<VirtualScrollerProps<T>>(), {
  autoPlay: false,
  autoPlayDelay: 500,
  autoRepeat: true,
  assumedHeight: 30,
  paddingQuantity: 6,
  draggable: false,
  dragHandleSelector: '.sortable-drag-handle',
  dragClass: 'sortable-drag',
  dragGhostClass: 'sortable-ghost',
  dragChosenClass: 'sortable-chosen'
});

const emit = defineEmits<VirtualScrollerEmits>();

defineSlots<VirtualScrollerSlots<T>>();

const virtualScroller = new VirtualScroller.Class<T>(props, emit);

// THE STATE DESTRUCTURE — every Ref/Computed the template touches, grouped.
// Methods and plain getters stay DOTTED on the instance.
const {
  // computed refs
  visibleItems,
  // element refs
  scrollElement,
  scrollElementInner,
  itemsWrapperElement
} = virtualScroller;

defineExpose(virtualScroller as VirtualScroller.Instance<T>);
</script>
<template>
  <div ref="scrollElement" class="virtual-scroller" @scroll="virtualScroller.onScroll">
    <!-- Content-sized on purpose — NO explicit height. The inner is the
         composited layer; sized to the full virtual content (~10M px on a
         100k-item list) it carried visible compositor heaviness. The lead
         spacer is render-rebased and the tail is capped, so the layer stays
         a few hundred k px regardless of list size; the scroll range comes
         from the COMPUTED height via lenis.virtualLimit, not from the DOM. -->
    <div ref="scrollElementInner" class="virtual-scroller-inner">
      <!-- The whole leading/trailing content, reduced to two empty divs.
           Rendered items flow normally between them at their real heights. -->
      <div :style="{ height: virtualScroller.leadingSpacerPx }"></div>
      <div ref="itemsWrapperElement" :style="{ width: '100%' }">
        <VirtualScrollerItem
          v-for="element in visibleItems"
          :key="element.id"
          class="virtual-scroller__item"
          :index="element.index"
          @size-updated="(height) => virtualScroller.syncItemHeight(element.index, height)"
        >
          <slot name="item" v-bind="element"></slot>
        </VirtualScrollerItem>
      </div>
      <div :style="{ height: virtualScroller.trailingSpacerPx }"></div>
    </div>
  </div>
</template>
<style>
.virtual-scroller {
  height: 100%;
  overflow: auto;
  position: relative;
  scrollbar-width: none; /* Firefox */
  -ms-overflow-style: none; /* Internet Explorer 10+ */
  /* Items are normal-flow: without this, native scroll anchoring adjusts
     scrollTop whenever the spacers change, fighting the Lenis-driven
     translateY (scroll is virtual; scrollTop must stay 0). */
  overflow-anchor: none;
}
.virtual-scroller::-webkit-scrollbar {
  /* WebKit */
  width: 0;
  height: 0;
}

.virtual-scroller-inner {
  will-change: transform;
  transform: translateZ(0);
  backface-visibility: hidden;
  transform-style: preserve-3d;
  overscroll-behavior: contain;
}
</style>
vue
<script lang="ts" setup generic="T extends any">
import { onBeforeUnmount, onMounted, ref } from 'vue';

export interface VirtualScrollerItem {
  index: number;
}

export interface VirtualScrollItemEmits {
  (e: 'sizeUpdated', height: number): void;
}

defineProps<VirtualScrollerItem>();

const emit = defineEmits<VirtualScrollItemEmits>();

const item = ref<HTMLElement | null>(null);

/**
 * ONE-SHOT height capture — deliberately not a ResizeObserver. Items render
 * in normal flow, so the browser positions them at their real height with no
 * bookkeeping; the parent only needs heights for its spacer/estimate math.
 * Capture once on mount (seeds the estimate the moment the item enters the
 * window — keeps window-local index→position math as accurate as the old
 * always-observed map) and once right before unmount (the final height — the
 * only one that matters once the item leaves the window). Continuous
 * observation is what caused measurable jitter at 100k items: bursts of
 * resize callbacks during scroll, each invalidating geometry.
 */
const capture = () => {
  const el = item.value;
  if (!el) return;
  // Heights are recorded in LAYOUT px: an ancestor transform scale (the
  // post card scales to fit the window) shrinks every rect readout, and a
  // height map built from scaled values diverges from the real flow by the
  // scale factor — landing every index-targeted jump short. Derive the
  // current scale from the parent stack's rect-to-layout ratio and divide
  // it out.
  const parent = el.parentElement;
  const scale =
    parent && parent.offsetHeight > 0
      ? parent.getBoundingClientRect().height / parent.offsetHeight
      : 1;
  const height = el.getBoundingClientRect().height;
  emit('sizeUpdated', scale > 0 ? height / scale : height);
};

onMounted(capture);
onBeforeUnmount(capture);
</script>
<template>
  <div ref="item" class="virtual-scroller__item" :aria-rowindex="index + 1">
    <slot />
  </div>
</template>
<style>
.virtual-scroller__item {
  /* flow-root contains child margins (as the old absolutely-positioned
     items did via their own block formatting context), so flow stacking
     reproduces the exact same geometry the measured `top` offsets had. */
  display: flow-root;
}
</style>
vue
<script setup lang="ts">
/**
 * The VirtualScroller example, live in the docs. The scroller, its class,
 * and the customized Lenis in this folder are the production files —
 * extracted from an app where they drive 100k-item feeds. Only this
 * wrapper (the data and the chrome) is docs code.
 */
import { computed, ref } from 'vue';
import DemoBox from '../DemoBox.vue';
import VirtualScroller from '../../../../../examples/playground/src/examples/virtual-scroller/VirtualScroller.vue';
import type { VirtualScrollerExposedUnwrapped } from '../../../../../examples/playground/src/examples/virtual-scroller/VirtualScroller.vue';
import type { BaseItem } from '../../../../../examples/playground/src/examples/virtual-scroller/VirtualScroller.types';

const ITEM_COUNT = 1_000_000;

const OPENERS = [
  'Everything costs proportional to what is observed',
  'The window walks; the list stands still',
  'A million rows, a handful of divs',
  'Estimates decide the spacers; real heights decide the rest',
  'Scroll is virtual — the DOM never learns the total',
  'Heights are captured once, on the way in and on the way out',
];

// One million rows must stay memory-sane: bodies are 24 SHARED string
// variants (opener × padding length) — unique text per row would be
// hundreds of MB of strings. The row number renders from `position`.
const BODY_VARIANTS: string[] = [];
for (let openerIndex = 0; openerIndex < OPENERS.length; openerIndex++) {
  for (let extraSentences = 0; extraSentences < 4; extraSentences++) {
    let body = `${OPENERS[openerIndex]}.`;
    for (let extra = 0; extra < extraSentences; extra++) {
      body +=
        ' Rendered rows are normal-flow blocks between two spacer divs, so the browser stacks them at their real heights for free.';
    }
    BODY_VARIANTS.push(body);
  }
}

function buildItems(): BaseItem[] {
  const items = new Array(ITEM_COUNT);
  for (let index = 0; index < ITEM_COUNT; index++) {
    items[index] = {
      id: String(index),
      body: BODY_VARIANTS[(index * 7) % BODY_VARIANTS.length],
      position: String(index + 1),
    };
  }
  return items;
}

const items = ref<BaseItem[]>(buildItems());
const scroller = ref<VirtualScrollerExposedUnwrapped<BaseItem> | null>(null);

// the scroller's own reactive state — flips off when the reader scrolls up
const isAutoPlaying = computed(() => scroller.value?.isAutoPlaying ?? false);

function jumpTo(index: number) {
  scroller.value?.scrollToIndex(index, undefined, true, 12);
}

function toggleAutoPlay() {
  if (!scroller.value) return;
  if (isAutoPlaying.value) {
    scroller.value.stopAutoPlay();
  } else {
    scroller.value.startAutoPlay(0);
  }
}
</script>

<template>
  <DemoBox
    title="Virtual scroller — 1,000,000 rows, Lenis-driven"
    note="The list is virtual: two spacer divs stand in for everything off-screen, rendered rows flow at their real heights, and a customized Lenis drives the scroll over translateY — the DOM never holds more than the window plus padding. Wheel, drag the touch way, or jump — landings converge as real heights measure in."
  >
    <div class="d-vals evs-stats">
      <div>
        <div class="d-k">items in the list</div>
        <div class="d-n">{{ ITEM_COUNT.toLocaleString() }}</div>
      </div>
      <div>
        <div class="d-k">rows in the DOM</div>
        <div class="d-n grad">{{ scroller?.visibleItems.length ?? 0 }}</div>
      </div>
    </div>

    <div class="evs-frame">
      <VirtualScroller
        ref="scroller"
        v-model="items"
        :assumed-height="56"
        :padding-quantity="10"
        auto-play
        :auto-play-delay="800"
      >
        <template #item="{ item }">
          <div class="evs-row">
            <b>#{{ Number(item.position).toLocaleString() }}</b> — {{ item.body }}
          </div>
        </template>
      </VirtualScroller>
    </div>

    <div class="d-row">
      <button class="d-btn primary" type="button" @click="jumpTo(499999)">
        jump to #500,000
      </button>
      <button class="d-btn" type="button" @click="jumpTo(ITEM_COUNT - 1)">
        jump to the end
      </button>
      <button class="d-btn" type="button" @click="jumpTo(0)">
        back to the top
      </button>
      <button
        class="d-btn"
        :class="{ 'evs-playing': isAutoPlaying }"
        type="button"
        @click="toggleAutoPlay"
      >
        <span class="evs-btn-icon">{{ isAutoPlaying ? '⏸' : '▶' }}</span>
        {{ isAutoPlaying ? 'pause autoplay' : 'autoplay' }}
      </button>
    </div>
  </DemoBox>
</template>

<style scoped>
.evs-stats {
  margin-bottom: 14px;
}
.evs-frame {
  height: 440px;
  border: 1px solid rgba(148, 163, 184, 0.16);
  border-radius: 10px;
  background: rgba(255, 255, 255, 0.02);
  overflow: hidden;
}
.evs-row {
  padding: 12px 16px;
  border-bottom: 1px solid rgba(148, 163, 184, 0.1);
  font-size: 13.5px;
  line-height: 1.6;
  color: #b7c0dc;
}
.evs-row :deep(b) {
  color: #7dd3fc;
  font-weight: 700;
}

.evs-btn-icon {
  margin-right: 6px;
}
.d-btn.evs-playing {
  border-color: rgba(52, 211, 153, 0.6);
  background: rgba(52, 211, 153, 0.1);
  color: #34d399;
}
</style>

The example lives in the unified playground at examples/playground/ — the customized Lenis (virtual-limit support over the stock engine) is vendored inside it. Run it without cloning anything:

Open in StackBlitz ⚡ — StackBlitz imports the folder straight from GitHub, so every push redeploys the example automatically.

What to notice

  • Rows in the DOM stays at the window size while you fly through 1,000,000 items — watch the counter while scrolling.
  • Jumps converge. A jump lands on an estimated position, then re-applies as the fresh window measures in — watch the landing settle onto row #500,000.
  • The scrollbar is code. overflow-anchor: none and a translateZ compositor layer keep the browser out of the way; Lenis takes its clamp from the computed content height, not the DOM.

Released under the MIT License.