Skip to content

Horizontal scroller: 1,000,000 cards, the vertical class sideways

This is not a second scroller. It is the vertical 1M scroller — the same tuned class, with the same prefix-sum cursor, origin rebasing, creep integrator and seek pipeline — subclassed through its eight axis seams. Every place the base class touches a DOM dimension or a gesture axis is a protected getter or method; the subclass overrides exactly those — each one carrying the override keyword, which TypeScript's noImplicitOverride option makes mandatory, so a seam can never be overridden silently or orphaned by a base rename — and the hand-tuned 80% runs sideways, unchanged. This one subclass powers three shipped surfaces: the card strip above, the home page's blog drip, and — composed with a pure text chunker — a ~400k-character book scrolling as one line. The full story is in Ship the variant, keep the tuning.

The reference for the module standard

HorizontalVirtualScroller.ts doubles as the canonical shape of an ivue module. A class file has exactly three residents — imports, the class, and the namespace, which carries everything else in canonical section order:

  • Identity$Class (raw, for children to extend), Class (Reactive(), for you to new), Instance (the unwrapping-surface type).

  • Values — the component contract as plain data: propsTypes, propsDefaults, the merged props (via propsWithDefaults), emits. Being data is what lets a subclass compose its surface by spread — every prop inherited, one default overridden, with the reason on the line.

  • Types — all derived from the values (Props, Emits, Slots, Exposed), never hand-duplicated. Constants a module keeps to itself live in the namespace un-exported: private to the file, no module-level residue.

  • Generic typing — the part that makes this the FULL canonical example: the scroller is a generic component (<T extends BaseItem>), and the namespace pattern carries the generic through every layer TS makes awkward. Reactive() returns the same constructor, but its return type cannot carry <T> (TypeScript has no higher-kinded types), so Class is cast back to the raw constructor type to keep new VirtualScroller.Class<T>() fully generic, and Instance<T> applies ReactiveInstance by hand. A runtime props map cannot carry a type parameter either, so Props<T> grafts it back over the one prop that needs it: Omit<ExtractPropTypes<typeof props>, 'modelValue'> & { modelValue: T[] }. Exposed<T> closes the loop for template refs.

The SFC is pure wiring against that contract — and generic wiring: <script setup generic="T extends BaseItem"> hands the runtime props object to defineProps (no compiler macro ever resolves a cross-file type) and one cast recovers the precision the runtime map cannot carry:

ts
const props = defineProps(
  VirtualScroller.props
) as unknown as VirtualScroller.Props<T>;

The state destructure is the only other thing in <script setup>.

The source

The subclass first — the two files that ARE the horizontal scroller — then the base machinery they inherit, exactly as running above:

ts
import type { ShallowUnwrapRef } from 'vue';

import {
  propsWithDefaults,
  Reactive,
  type ReactiveInstance
} from '../../ivue';
import type { BaseItem } from './VirtualScroller.types';
import { VirtualScroller } from './VirtualScroller';

/**
 * The virtual scroller, sideways — the extension story on the flagship
 * component. The tuned vertical class ships untouched: every place it
 * touches a DOM dimension or a gesture axis goes through the axis seams
 * (offsetSize / rectSize / transformFor / axisPaddingProps / axisDelta /
 * container sizes), and this subclass overrides ONLY those. The prefix-sum
 * cursor, render-bias rebasing, snap policy, creep integrator, and the
 * seek/converge loop — the hand-tuned 80% — run here unchanged, just over
 * widths instead of heights.
 *
 * Gestures are deltaX-only: shift+wheel and horizontal trackpad swipes
 * drive the strip; a plain vertical wheel scrolls the page straight
 * through. Pair with `snap-to-items` for the step feel: scroll, stop.
 */
class $HorizontalVirtualScroller<T extends BaseItem> extends (VirtualScroller.$Class as typeof VirtualScroller.$Class) <T> {
  protected override get lenisOrientation(): 'vertical' | 'horizontal' {
    // lenis writes the wheel-path transform itself — translateX only when
    // it knows the axis
    return 'horizontal';
  }

  protected override get lenisIgnoreNativeScroll(): boolean {
    return true;
  }

  protected override get lenisGestureOrientation(): 'vertical' | 'horizontal' | 'both' {
    // deltaX ONLY: a plain vertical wheel is the page's (lenis refuses it
    // before preventDefault, so the page scrolls straight through); the
    // strip answers to shift+wheel and real horizontal trackpad swipes.
    return 'horizontal';
  }

  protected override offsetSize(element: HTMLElement | null | undefined): number {
    return element?.offsetWidth ?? 0;
  }

  protected override rectSize(element: Element): number {
    return element.getBoundingClientRect().width;
  }

  protected override transformFor(px: number): string {
    return 'translateX(' + px + 'px)';
  }

  protected override get axisPaddingProps(): readonly [string, string] {
    return ['padding-left', 'padding-right'];
  }

  protected override axisDelta(data: { deltaX: number; deltaY: number }): number {
    return data.deltaX;
  }

  protected override get axisThumbProps(): readonly [string, string] {
    return ['width', 'left'];
  }

  protected override trackPointerFraction(event: PointerEvent, rect: DOMRect): number {
    return (event.clientX - rect.left) / rect.width;
  }

  override get containerSize() {
    return this.elementSize.width;
  }

  override get containerOuterSize() {
    return this.outerElementSize.width;
  }
}

/** Standard namespace pattern, generic adaptation — see VirtualScroller's
 *  note on why `Class` casts back and `Instance<T>` applies
 *  ReactiveInstance by hand. The props surface extends the same way the
 *  class does: spread the parent's maps, override what defines the
 *  specialization — here a single default (cards are ~hundreds of px
 *  wide where rows are tens tall), stated once instead of duplicating
 *  the whole defaults block. */
export namespace HorizontalVirtualScroller {
  /* Identity */

  export const $Class = $HorizontalVirtualScroller;
  export let Class = Reactive(
    $HorizontalVirtualScroller
  ) as unknown as typeof $HorizontalVirtualScroller;
  export type Instance<T extends BaseItem> = ReactiveInstance<
    $HorizontalVirtualScroller<T>
  >;

  /* Values */

  export const propsTypes = { ...VirtualScroller.propsTypes };
  export const propsDefaults = {
    ...VirtualScroller.propsDefaults,
    assumedSize: 300
  };
  export const props = propsWithDefaults(propsDefaults, propsTypes);
  export const emits = VirtualScroller.emits;

  /* Types */

  export type Props<T extends BaseItem> = VirtualScroller.Props<T>;
  export type Emits = VirtualScroller.Emits;
  export type Slots<T extends BaseItem> = VirtualScroller.Slots<T>;
  /** See VirtualScroller.Exposed — same surface, this class's instance. */
  export type Exposed<T extends BaseItem> = ShallowUnwrapRef<Instance<T>>;
}
vue
<script lang="ts" setup generic="T extends BaseItem">
import { HorizontalVirtualScroller } from './HorizontalVirtualScroller';
import type { BaseItem } from './VirtualScroller.types';
import VirtualScrollerItem from './VirtualScrollerItem.vue';

// Pure wiring — the namespace carries the whole contract, with the
// horizontal defaults already merged (assumedSize 300 overridden by
// spread in HorizontalVirtualScroller.propsDefaults).
const props = defineProps(
  HorizontalVirtualScroller.props
) as unknown as HorizontalVirtualScroller.Props<T>;

const emit = defineEmits(
  HorizontalVirtualScroller.emits
) as HorizontalVirtualScroller.Emits;

defineSlots<HorizontalVirtualScroller.Slots<T>>();

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

// THE STATE DESTRUCTURE — every Ref/Computed the template touches, grouped.
const {
  // state refs
  scrollbarDragging,
  // computed refs
  visibleItems,
  // element refs
  scrollElement,
  scrollElementInner,
  itemsWrapperElement
} = virtualScroller;

defineExpose(virtualScroller as HorizontalVirtualScroller.Instance<T>);
</script>
<template>
  <div ref="scrollElement" class="virtual-scroller virtual-scroller--x" @scroll="virtualScroller.onScroll">
    <!-- Same layer discipline as the vertical scroller (content-sized inner,
         rebased leading spacer, capped tail), rotated: spacers are widths
         and items flow in a row. -->
    <div ref="scrollElementInner" class="virtual-scroller-inner virtual-scroller-inner--x">
      <div :style="{ width: virtualScroller.leadingSpacerPx, flex: '0 0 auto' }"></div>
      <div ref="itemsWrapperElement" class="virtual-scroller__row">
        <VirtualScrollerItem
          v-for="element in visibleItems"
          :key="element.id"
          class="virtual-scroller__item virtual-scroller__item--x"
          :index="element.index"
          axis="x"
          @size-updated="(width) => virtualScroller.syncItemSize(element.index, width)"
        >
          <slot name="item" v-bind="element"></slot>
        </VirtualScrollerItem>
      </div>
      <div :style="{ width: virtualScroller.trailingSpacerPx, flex: '0 0 auto' }"></div>
    </div>
    <div
      v-if="virtualScroller.scrollbarVisible"
      class="virtual-scroller__track virtual-scroller__track--x"
      @pointerdown="virtualScroller.onTrackPointerDown"
      @pointermove="virtualScroller.onTrackPointerMove"
      @pointerup="virtualScroller.onTrackPointerUp"
      @pointercancel="virtualScroller.onTrackPointerUp"
    >
      <div
        class="virtual-scroller__thumb virtual-scroller__thumb--x"
        :class="{ dragging: scrollbarDragging }"
        :style="virtualScroller.scrollbarThumbStyle"
      ></div>
    </div>
  </div>
</template>
<style>
.virtual-scroller--x {
  height: auto;
  overflow: hidden;
  /* own positioning context — the absolute track must not depend on the
     vertical stylesheet's .virtual-scroller rules being loaded */
  position: relative;
  /* horizontal gestures belong to the strip; vertical stays the page's */
  touch-action: pan-y;
}
.virtual-scroller-inner--x {
  display: flex;
  flex-direction: row;
  align-items: stretch;
  width: max-content;
}
.virtual-scroller__row {
  display: flex;
  flex-direction: row;
  align-items: stretch;
}
.virtual-scroller__item--x {
  display: block;
  flex: 0 0 auto;
}
/* The built-in track, rotated: it hugs the bottom edge and the thumb
   travels left→right. SELF-CONTAINED on purpose — this component loads
   without the vertical scroller's stylesheet (the home strip) — and the
   selectors double up (.track.track--x) so they out-specify the vertical
   base rules when both stylesheets ARE on the page (the docs demos). */
.virtual-scroller__track.virtual-scroller__track--x {
  position: absolute;
  top: auto;
  left: 10px;
  right: 10px;
  bottom: 4px;
  width: auto;
  height: 12px;
  cursor: pointer;
  touch-action: none;
  z-index: 1;
}
.virtual-scroller__track.virtual-scroller__track--x::before {
  content: '';
  position: absolute;
  inset: 4px 0;
  border-radius: 999px;
  background: rgba(148, 163, 184, 0.18);
}
.virtual-scroller__thumb.virtual-scroller__thumb--x {
  position: absolute;
  left: auto;
  right: auto;
  top: 2px;
  bottom: 2px;
  border-radius: 999px;
  background: rgba(148, 163, 184, 0.45);
  /* eased relocation, sideways: left/width glide instead of top/height */
  transition: background 0.15s ease, left 0.2s ease-out, width 0.2s ease-out;
}
.virtual-scroller__thumb.virtual-scroller__thumb--x.dragging {
  transition: background 0.15s ease;
}
.virtual-scroller__track--x:hover .virtual-scroller__thumb--x,
.virtual-scroller__thumb.virtual-scroller__thumb--x.dragging {
  background: rgba(148, 163, 184, 0.75);
}
</style>
ts
import type {
  ExtractPropTypes,
  PropType,
  Ref,
  ShallowUnwrapRef
} from 'vue';
import {
  computed,
  nextTick,
  onBeforeUnmount,
  onMounted,
  ref,
  toRaw,
  toRef,
  watch
} from 'vue';

import { useElementSize, useResizeObserver } from '@vueuse/core';
import {
  definePropTypes,
  propsWithDefaults,
  Reactive,
  type ExtractEmitTypes,
  type ExtractPropDefaultTypes,
  type ReactiveInstance
} from '../../ivue';
import { Lenis } from '../../lenis/lenis';
import type { BaseItem } from './VirtualScroller.types';

/**
 * 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 sizes for free; no
 * per-item `top` is computed or maintained. Estimates only decide the two
 * spacer sizes and the scrollTop↔index mapping: an item's estimated top
 * is the prefix sum `P(i) = Σ (measuredSizes[j] ?? assumedSize)` 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 size map, plus O(1) aggregates
 * (`measuredSum`/`measuredCount`) for the total content size. Heights are
 * captured ONE-SHOT (item mount + final size at item unmount — see
 * VirtualScrollerItem.vue), not continuously observed: a size 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: VirtualScroller.Props<T>,
    public emit: VirtualScroller.Emits
  ) {
    this.elementSize = useElementSize(this.scrollElement);
    this.outerElementSize = useElementSize(this.scrollElement, undefined, {
      box: 'border-box'
    });

    // ONE observer per scroller — on the items wrapper, whose size only
    // changes when a rendered item's real size does (spacers are siblings).
    // The callback re-reads just the rendered window (O(window), never
    // O(total)). This is what keeps rendered-item sizes truthful for the
    // scroll clamps and index→position math: slot content hydrates a tick
    // after item mount (mount-time capture reads the pre-hydration size),
    // 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,
        orientation: this.lenisOrientation,
        gestureOrientation: this.lenisGestureOrientation,
        ignoreNativeScroll: this.lenisIgnoreNativeScroll,
        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 size — 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.scrollExtent.value - this.offsetSize(this.scrollElement.value)
        );
    });

    onBeforeUnmount(() => {
      clearTimeout(this.snapTimeout);
      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 assumedSize() {
    return toRef(this.props, 'assumedSize');
  }

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

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

  /* Axis seams — every place the class touches a DOM dimension or a
     gesture axis goes through these. Vertical defaults here; the
     horizontal subclass overrides ONLY these (the tuned scroll physics,
     cursor math, and creep never fork). */

  protected get lenisOrientation(): 'vertical' | 'horizontal' {
    return 'vertical';
  }

  protected get lenisGestureOrientation(): 'vertical' | 'horizontal' | 'both' {
    return 'vertical';
  }

  /** Fully-virtual axes refuse native-scroll adoption (see the fork's
   *  onNativeScroll) — the vertical scroller keeps the stock behavior. */
  protected get lenisIgnoreNativeScroll(): boolean {
    return false;
  }

  /** Main-axis border-box size of an element. */
  protected offsetSize(element: HTMLElement | null | undefined): number {
    return element?.offsetHeight ?? 0;
  }

  /** Main-axis rect size (screen px) of an element. */
  protected rectSize(element: Element): number {
    return element.getBoundingClientRect().height;
  }

  /** The transform that places the content at `px` along the main axis. */
  protected transformFor(px: number): string {
    return 'translateY(' + px + 'px)';
  }

  protected get axisPaddingProps(): readonly [string, string] {
    return ['padding-top', 'padding-bottom'];
  }

  /** The gesture delta that drives the main axis. */
  protected axisDelta(data: { deltaX: number; deltaY: number }): number {
    return data.deltaY;
  }

  /** Scrollbar-thumb style properties along the main axis: [size, offset]. */
  protected get axisThumbProps(): readonly [string, string] {
    return ['height', 'top'];
  }

  /** 0..1 position of a pointer along the scrollbar track's main axis. */
  protected trackPointerFraction(event: PointerEvent, rect: DOMRect): number {
    return (event.clientY - rect.top) / rect.height;
  }

  /* Container size */

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

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

  /**
   * Border-box container size — the same box setScrollPosition's bottom
   * clamp measures (offsetHeight). Seek math must use THIS, not the
   * content-box containerSize: the padding difference is invisible on a
   * huge post but parks the knob 10-15% short of the end on a small one.
   */
  get containerOuterSize() {
    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 main-axis pixel sizes by item index (unmeasured fall back to assumedSize). */
  get measuredSizes() {
    return ref<Record<number, number>>({});
  }

  /**
   * Bumped whenever item geometry may have changed (size sync, structural
   * repair). The reactive invalidation signal for visibleItems/scrollExtent/
   * 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 sizes of every item before `index`) under the
   * current measuredSizes/assumedSize/items — maintained O(1) in
   * syncItemSize 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 measuredSizes — non-reactive, see cursor. */
  private measuredSum = 0;

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

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

  /**
   * The size assumed for unmeasured items. Starts as the assumedSize
   * 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 —
   * scrollExtent, 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 estimatedItemSize() {
    return this.calibratedAssumed ?? this.assumedSize.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.containerSize.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 sizes 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 leadingSpacerSize() {
    return ref(0);
  }

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

  get leadingSpacerPx() {
    return (
      $VirtualScroller.snapForRender(
        Math.max(0, this.leadingSpacerSize.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
   *  size via lenis.virtualLimit. */
  private static readonly TRAILING_SPACER_RENDER_CAP = 2048;

  get trailingSpacerPx() {
    return (
      $VirtualScroller.snapForRender(
        Math.min(
          $VirtualScroller.TRAILING_SPACER_RENDER_CAP,
          this.trailingSpacerSize.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 scrollExtent() {
    // THIN computed — the caching shell only; the logic stays named in a
    // directly testable method on the prototype.
    return computed(() => this.computeScrollExtent());
  }

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

    if (len === 0) return 0;

    /** Account for the scroller's main-axis padding (top/bottom vertical,
     * left/right horizontal — see axisPaddingProps). */
    let paddingStart = 0;
    let paddingEnd = 0;
    if (this.scrollElement.value) {
      const computedStyle = window.getComputedStyle(
        this.scrollElement.value,
        null
      );
      const [paddingStartProp, paddingEndProp] = this.axisPaddingProps;
      paddingStart = parseInt(computedStyle.getPropertyValue(paddingStartProp));
      paddingEnd = parseInt(computedStyle.getPropertyValue(paddingEndProp));
    }

    // 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.estimatedItemSize +
      paddingStart +
      paddingEnd
    );
  }

  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: VirtualScroller.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 size
   *   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 computeScrollExtent's note.
    return computed(() => this.computeVisibleItems());
  }

  private computeVisibleItems(): VirtualScroller.ItemContext<T>[] {
    this.geometryVersion.value;
    const items = this.items.value;
    const len = items.length;
    const measured = toRaw(this.measuredSizes.value);
    const assumed = this.estimatedItemSize;
    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 step;
      while (
        start < len - 1 &&
        startOffset + (step = measured[start] ?? assumed) <= scrollTop
      ) {
        startOffset += step;
        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 size.
    let end = start;
    let endOffset = startOffset;
    const bottom = startOffset + this.containerSize.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 size correction above the window moved only the lead).
    this.leadingSpacerSize.value = paddedStartOffset;
    this.trailingSpacerSize.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: VirtualScroller.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: VirtualScroller.ItemsChangeEmitArgs) {
    this.emit('itemsChanged', args);
  }

  /* Positions */

  /**
   * Structural repair: re-derive the aggregates and the cursor offset from
   * the current size 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-size-sync hot path never comes through here.
   */
  updatePositionsImmediately() {
    const measured = toRaw(this.measuredSizes.value);
    const assumed = this.estimatedItemSize;
    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.measuredSizes.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 size = measured[index];
      if (size === undefined) continue;
      sum += size;
      count++;
      if (index < cursorIndex) {
        sumBeforeCursor += size;
        countBeforeCursor++;
      }
    }
    this.measuredSum = sum;
    this.measuredCount = count;
    this.cursor.index = cursorIndex;
    this.cursor.offset =
      sumBeforeCursor + (cursorIndex - countBeforeCursor) * assumed;

    this.bumpGeometryVersion();
  }

  /**
   * Re-read the real sizes 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 sizes 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 size by the scale factor while the
    // flow renders at full layout size — 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 wrapperSize = this.offsetSize(wrapper);
    const scale = wrapperSize > 0 ? this.rectSize(wrapper) / wrapperSize : 1;
    const measured = toRaw(this.measuredSizes.value);
    let changed = false;
    const sizes: [number, number][] = [];
    for (const el of rendered) {
      const row = el.getAttribute('aria-rowindex');
      if (row === null) continue;
      sizes.push([+row - 1, this.rectSize(el) / (scale > 0 ? scale : 1)]);
    }
    for (const [index, size] of sizes) {
      if (measured[index] !== size) {
        this.syncItemSize(index, size, false);
        changed = true;
      }
    }
    if (changed) {
      this.bumpGeometryVersion();
      this.maybeCalibrateEstimate();
    }
  }

  syncItemSize(index: number, size: 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 (size == null) delete this.measuredSizes.value[index];
      else this.measuredSizes.value[index] = size;
      if (doUpdatePositions) this.bumpGeometryVersion();
      return;
    }
    const assumed = this.estimatedItemSize;
    const previous = toRaw(this.measuredSizes.value)[index];
    // O(1) bookkeeping that keeps the aggregates and the cursor invariant
    // (`offset === P(index)`) exact — sizes before the cursor shift it.
    if (size == null) {
      // Callers copy neighbor sizes that may not exist — undefined means
      // "unmeasured": drop the entry so the item falls back to assumedSize
      // (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.measuredSizes.value[index];
      }
      if (doUpdatePositions) this.bumpGeometryVersion();
      return;
    }
    if (previous === undefined) {
      this.measuredCount++;
      this.measuredSum += size;
    } else {
      this.measuredSum += size - previous;
    }
    if (index < this.cursor.index) {
      this.cursor.offset += size - (previous ?? assumed);
    }
    this.measuredSizes.value[index] = size;
    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.measuredSizes.value);
    const assumed = this.estimatedItemSize;
    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 size-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 size
   * wave cuts the promised item moments after landing. The seek settle
   * re-applies this same clamped map at refined sizes, so the gap holds
   * once the real sizes are in. Cost: the last `endGapPx` of each item
   * is a scrub dead-zone — invisible next to typical item sizes.
   */
  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 sizes 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 size =
      toRaw(this.measuredSizes.value)[index] ?? this.estimatedItemSize;
    return base + fraction * size;
  }

  /**
   * 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.measuredSizes.value);
    const assumed = this.estimatedItemSize;
    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 size = measured[index] ?? assumed;
    while (
      index < len - 1 &&
      top + (size = measured[index] ?? assumed) <= offset
    ) {
      top += size;
      index++;
    }
    size = measured[index] ?? assumed;
    cursor.index = index;
    cursor.offset = top;
    return {
      index,
      fraction:
        size > 0 ? Math.min(1, Math.max(0, (offset - top) / size)) : 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;
    const containerSize = this.offsetSize(this.scrollElement.value);
    if (position > 0 || this.scrollExtent.value < containerSize) position = 0;

    // Prevent scrolling down beyond last paragraph
    if (
      Math.abs(position) +
      containerSize +
      (this.scrollElement.value?.scrollTop ?? 0) >
      this.scrollExtent.value &&
      this.scrollExtent.value > containerSize
    ) {
      position = -(
        // Must be negative
        this.scrollExtent.value -
        containerSize -
        (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 = this.transformFor(
        snapRender ? $VirtualScroller.snapForRender(rendered) : rendered
      );
      // 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;
  }

  /** Scrollbar geometry over the VIRTUAL position (native scrollTop stays
   *  0 by design, so a native scrollbar can never exist here). Fraction of
   *  the track the thumb occupies — floored so a million-item list still
   *  presents a grabbable thumb. */
  get scrollbarThumbFraction() {
    const total = this.scrollExtent.value;
    const container = this.containerOuterSize.value;
    if (!total || !container || total <= container) return 0;
    return Math.max(container / total, 0.08);
  }

  /** 0..1 progress of the thumb along its travel range. */
  get scrollbarProgress() {
    const total = this.scrollExtent.value;
    const scrollable = total - this.containerOuterSize.value;
    if (scrollable <= 0) return 0;
    const position = parseFloat(String(this.scrollPosition.value)) || 0;
    return Math.min(Math.max(position / scrollable, 0), 1);
  }

  /** Seek to a 0..1 track fraction in ITEM-INDEX space through the full
   *  scrollToIndex pipeline (spacer rebase + converge loop) — a raw
   *  lenis.scrollTo would translate content out of the viewport without
   *  rebasing the window. Index space is the external seek-bar contract:
   *  the landing promises an ITEM, size-independent, so it survives the
   *  estimate→real refinement. */
  seekToFraction(fraction: number) {
    const lastIndex = this.items.value.length - 1;
    if (lastIndex < 0) return;
    const clamped = Math.min(Math.max(fraction, 0), 1);
    this.scrollToIndex(Math.round(clamped * lastIndex), undefined, false);
  }

  /**
   * Seek to a 0..1 fraction of the SCROLLABLE RANGE — the exact inverse
   * of scrollbarProgress, which is what the built-in track needs: the
   * thumb RENDERS position-space, so its drag must land where it points.
   * Index space cannot express this when one item outsizes the container
   * (a marquee chunk is ~3 containers wide): the last item's START is
   * far from the end of the content, so an index-anchored drag leaves the
   * tail unreachable. The target position still resolves to an item plus
   * an in-item fraction and rides the scrollToIndex converge loop, so the
   * landing stays on the same CONTENT as late sizes refine.
   */
  seekToProgress(fraction: number) {
    const clamped = Math.min(Math.max(fraction, 0), 1);
    const container = this.offsetSize(this.scrollElement.value);
    const target = clamped * Math.max(0, this.scrollExtent.value - container);
    const at = this.getIndexAtPosition(target);
    if (!at) return;
    this.scrollToIndex(at.index, undefined, false, 0, at.fraction);
  }

  /* Scrollbar drag (the built-in track) */

  /** True while a pointer owns the thumb — the thumb's easing turns off so
   *  it sticks to the finger (see the .dragging CSS). */
  get scrollbarDragging() {
    return ref(false);
  }

  /** The track renders only when asked for AND there is travel to show. */
  get scrollbarVisible() {
    return this.props.scrollbar && this.scrollbarThumbFraction > 0;
  }

  /** The thumb's size and offset along the track — main-axis property
   *  names come from the axis seam, so the same geometry renders as
   *  height/top on the vertical track and width/left on the horizontal. */
  get scrollbarThumbStyle() {
    const [sizeProp, offsetProp] = this.axisThumbProps;
    return {
      [sizeProp]: this.scrollbarThumbFraction * 100 + '%',
      [offsetProp]:
        this.scrollbarProgress * (1 - this.scrollbarThumbFraction) * 100 + '%'
    };
  }

  onTrackPointerDown(event: PointerEvent) {
    this.stopAutoPlay();
    this.scrollbarDragging.value = true;
    (event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
    this.seekToPointer(event);
  }

  onTrackPointerMove(event: PointerEvent) {
    if (this.scrollbarDragging.value) this.seekToPointer(event);
  }

  onTrackPointerUp() {
    this.scrollbarDragging.value = false;
  }

  seekToPointer(event: PointerEvent) {
    const track = (event.currentTarget as HTMLElement).closest(
      '.virtual-scroller__track'
    ) as HTMLElement;
    if (!track) return;
    this.seekToProgress(
      this.trackPointerFraction(event, track.getBoundingClientRect())
    );
  }

  /** Main-axis offset that places item `index` per the snapAlign prop —
   *  0 for 'start'; half the free space for 'center' (clamped landings at
   *  the bounds come free from setScrollPosition's own clamps). */
  snapAlignOffset(index: number): number {
    if (this.props.snapAlign !== 'center') return 0;
    const size =
      toRaw(this.measuredSizes.value)[index] ?? this.estimatedItemSize;
    // The rendered flow starts AFTER the container's leading main-axis
    // padding, but prefix-sum positions do not include it — subtract it,
    // or every "centered" landing sits paddingStart px past center.
    return Math.max(
      0,
      (this.offsetSize(this.scrollElement.value) - size) / 2 -
        this.mainAxisPaddingStart()
    );
  }

  /** Leading main-axis padding of the scroll container (see
   *  axisPaddingProps) — the offset between position space and the
   *  rendered flow. */
  protected mainAxisPaddingStart(): number {
    const element = this.scrollElement.value;
    if (!element) return 0;
    const [paddingStartProp] = this.axisPaddingProps;
    return (
      parseInt(
        window.getComputedStyle(element).getPropertyValue(paddingStartProp)
      ) || 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 size keeps refining
   * through the settle loop, so this converges onto the real text position.
   */
  scrollToIndex(
    index: number,
    afterCallback?: () => void,
    animate = true,
    /** Defaults to the snapAlign placement — pass an explicit value to
     *  override it (0 = flush to the container start). */
    topOffsetPx = this.snapAlignOffset(index),
    innerFraction = 0
  ) {
    const targetPosition = () => {
      const position = this.getIndexPosition(index);
      if (position === undefined) return undefined;
      const size =
        toRaw(this.measuredSizes.value)[index] ?? this.estimatedItemSize;
      return Math.max(0, position + innerFraction * size - 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 size 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({ deltaX, deltaY }) {
    const delta = this.axisDelta({ deltaX, 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 && delta < 0) {
      this.stopAutoPlay();
    } else if (!this.isAutoPlaying.value && delta > 0 && !this.props.snapToItems) {
      this.isAutoPlaying.value = true;
    }
    this.virtualScrolling = true;
    clearTimeout(this.virtualScrollTimeout);
    this.scrollElementInner.value.style.transitionDuration = '0s';
    this.scrollDirection.value = delta < 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);

    if (this.props.snapToItems) {
      // step mode: once the input rests AND the lenis lerp settles, the
      // strip snaps to the nearest item boundary through the same
      // scrollToIndex pipeline a seek uses.
      clearTimeout(this.snapTimeout);
      this.snapTimeout = setTimeout(this.snapToNearest, 160);
    }
  }

  private snapTimeout: ReturnType<typeof setTimeout> | undefined;

  snapToNearest() {
    if (this.virtualScrolling || this.lenis?.isScrolling) {
      clearTimeout(this.snapTimeout);
      this.snapTimeout = setTimeout(this.snapToNearest, 90);
      return;
    }
    const scrollPosition = this.scrollPosition.value;
    const offset =
      typeof scrollPosition === 'number'
        ? scrollPosition
        : parseFloat(scrollPosition) || 0;
    // 'start': the item nearest the container's leading edge. 'center':
    // the item under the container's center — that item then lands
    // centered (scrollToIndex's default alignment).
    const centered = this.props.snapAlign === 'center';
    // The probe point lives in POSITION space: the container's visual
    // center minus the leading padding that the rendered flow adds.
    const at = this.getIndexAtPosition(
      centered
        ? offset +
            this.offsetSize(this.scrollElement.value) / 2 -
            this.mainAxisPaddingStart()
        : offset
    );
    if (!at) return;
    const target = centered
      ? at.index
      : at.fraction > 0.5
        ? at.index + 1
        : at.index;
    this.scrollToIndex(
      Math.min(target, this.items.value.length - 1),
      undefined,
      true
    );
  }

  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;

  /** Speed as a SETTING: the optional creepMsPerPx prop overrides the
   *  tuned reading cadence (which stays the sacred default). A marquee
   *  reads a live value here every creep frame, so a speed slider takes
   *  effect mid-glide. */
  protected get creepMsPerPx(): number {
    return this.props.creepMsPerPx ?? $VirtualScroller.CREEP_MS_PER_PX;
  }

  /** 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);
      // Forward inertia decaying through cruise speed hands off to the
      // creep RIGHT THERE — the glide never dips below cruise.
      if (this.adoptDecayedInertia()) return;

      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 wheel-to-creep handoff: while a FORWARD flick's inertia decays,
   * the moment its speed falls to the creep's cruise speed the creep
   * adopts the scroll right there — a scrub may accelerate the glide
   * above cruise, but it never drags it below. Without this, play()
   * waits for the lenis lerp to decay all the way to zero before
   * resuming: decelerate, stall, accelerate — felt as a stutter after
   * every shift+wheel scrub. A backward scrub is the reader taking over
   * (stopAutoPlay already handled it), so no handoff there. Snap-mode
   * consumers never arm autoplay, so this path never runs for them.
   */
  private adoptDecayedInertia(): boolean {
    const lenis = this.lenis;
    // While input is still arriving, the reader owns the scroll — only a
    // free-decaying smooth lerp is a candidate.
    if (!lenis || this.virtualScrolling) return false;
    if (lenis.isScrolling !== 'smooth') return false;
    if (this.scrollDirection.value !== 'down') return false;
    // lenis.velocity is px per rAF frame; at ~60fps that is px per 16.7ms.
    const pxPerMs = lenis.velocity / 16.7;
    if (pxPerMs <= 0 || pxPerMs > 1 / this.creepMsPerPx) return false;
    // Adopt the CURRENT animated position (not the farther wheel target):
    // the lerp dies where it is and the creep continues from that exact
    // pixel at cruise speed — velocity is continuous through the handoff.
    lenis.adoptExternalScroll(lenis.animatedScroll);
    cancelAnimationFrame(this.frame);
    // 0 (not null): falsy for onVirtualScroll's re-arm check without
    // widening the field type.
    this.frame = 0;
    if (this.creepFrame !== null) cancelAnimationFrame(this.creepFrame);
    this.lastCreepTs = null;
    this.creepFrame = requestAnimationFrame(this.creepStep);
    return true;
  }

  /**
   * 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 integrates TRUTHFULLY on slow frames: a loaded machine's 60→20fps
    // jitter stays time-correct, so every displayed position is where the
    // clock says it should be. (Clamping Δt at 50ms made the advance
    // constant per FRAME — at marquee speeds that turns frame jitter into
    // visible speed wobble: 6px landing every 50–250ms reads as chop.)
    // Only a genuine rAF suspension (background tab) resumes as a fresh
    // frame instead of a content jump.
    const elapsed = this.lastCreepTs === null ? 16.7 : ts - this.lastCreepTs;
    const dt = elapsed > 250 ? 16.7 : elapsed;
    this.lastCreepTs = ts;
    this.lenis.targetScroll += dt / this.creepMsPerPx;

    const container = this.offsetSize(this.scrollElement.value);
    const atEnd =
      this.lenis.actualScroll + container >= this.scrollExtent.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.
 *
 * The namespace carries the WHOLE component contract, in canonical order:
 * IDENTITY ($Class / Class / Instance), then VALUES (prop types + defaults
 * merged by propsWithDefaults, the emits object), then TYPES (all derived
 * from the values — never hand-duplicated). One import gives a consumer or
 * a subclass everything the component is, and the SFC is pure wiring: the
 * macros receive the RUNTIME objects, so no compiler macro ever resolves a
 * cross-file type, and a subclass component composes its surface by
 * SPREADING the maps (see HorizontalVirtualScroller, which inherits every
 * prop and overrides one default in one line).
 */
export namespace VirtualScroller {
  /* Identity */

  export const $Class = $VirtualScroller;
  export let Class = Reactive(
    $VirtualScroller
  ) as unknown as typeof $VirtualScroller;
  export type Instance<T extends BaseItem> = ReactiveInstance<
    $VirtualScroller<T>
  >;

  /* Values */

  /** 1 — the TYPES: a defineComponent-style object, no defaults inside.
   *  `modelValue` is typed against BaseItem here (a const cannot be
   *  generic); Props<T> recovers the precise item type in the SFC. */
  export const propsTypes = definePropTypes({
    modelValue: { type: Array as PropType<BaseItem[]>, required: true },
    /** Render the built-in draggable scrollbar over the VIRTUAL position. */
    scrollbar: { type: Boolean as PropType<boolean> },
    autoPlay: { type: Boolean as PropType<boolean> },
    autoPlayDelay: { type: Number as PropType<number> },
    autoRepeat: { type: Boolean as PropType<boolean> },
    /** Step mode: after any input settles, snap to the nearest item
     *  boundary — scroll, stop; scroll, stop. */
    snapToItems: { type: Boolean as PropType<boolean> },
    /** Where a snapped/step landing places the item: at the container's
     *  start (the default) or its CENTER — `scroll-snap-align` semantics,
     *  clamped at the bounds like the platform's. Edge items that cannot
     *  center rest against the bounds; a consumer that wants true
     *  edge-centering adds main-axis padding (it flows into the extent
     *  through axisPaddingProps — the scroll-padding escape hatch). */
    snapAlign: { type: String as PropType<'start' | 'center'> },
    assumedSize: { type: Number as PropType<number> },
    paddingQuantity: { type: Number as PropType<number> },
    /** Autoplay creep speed: ms of wall time per px. No default on purpose —
     *  unset falls back to the tuned reading cadence (see creepMsPerPx). */
    creepMsPerPx: { type: Number as PropType<number> },
    /** Accepted for API compatibility; the docs build renders the plain branch. */
    draggable: { type: Boolean as PropType<boolean> },
    dragHandleSelector: { type: String as PropType<string> },
    dragClass: { type: String as PropType<string> },
    dragGhostClass: { type: String as PropType<string> },
    dragChosenClass: { type: String as PropType<string> }
  });

  /** 2 — the DEFAULTS: plain values, typed against the types object.
   *  Required props (`modelValue`) are filtered out by
   *  ExtractPropDefaultTypes itself; a deliberately default-free optional
   *  prop states its ruling in data: `creepMsPerPx: undefined` below means
   *  "unset = the tuned creep cadence". */
  export const propsDefaults: ExtractPropDefaultTypes<typeof propsTypes> = {
    scrollbar: false,
    autoPlay: false,
    autoPlayDelay: 500,
    autoRepeat: true,
    snapToItems: false,
    snapAlign: 'start',
    assumedSize: 30,
    paddingQuantity: 6,
    creepMsPerPx: undefined, // no default ON PURPOSE — see the comment above
    draggable: false,
    dragHandleSelector: '.sortable-drag-handle',
    dragClass: 'sortable-drag',
    dragGhostClass: 'sortable-ghost',
    dragChosenClass: 'sortable-chosen'
  };

  /** 3 — the MERGE: a standard Vue props object, ready for defineProps. */
  export const props = propsWithDefaults(propsDefaults, propsTypes);

  export const emits = {
    itemsChanged: (args: ItemsChangeEmitArgs) => true,
    drop: (startIndex: number, dropIndex: number) => true,
    move: (evt: any) => true
  };

  /* Types */

  /** Resolved props — what the class receives AFTER defaults are applied.
   *  DERIVED from the merged runtime object (never hand-duplicated):
   *  ExtractPropTypes makes every defaulted prop non-optional and the
   *  default-free `creepMsPerPx` optional; the one thing a runtime map
   *  cannot carry — the generic item type — is grafted back over
   *  `modelValue`. */
  export type Props<T extends BaseItem> = Omit<
    ExtractPropTypes<typeof props>,
    'modelValue'
  > & { modelValue: T[] };

  export type Emits = ExtractEmitTypes<typeof emits>;

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

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

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

  /**
   * 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 underneath:
   * it strips the readonly that TS puts on get-only accessors, so writes
   * like `scroller.scrollDirection = 'down'` typecheck as they behave.
   */
  export type Exposed<T extends BaseItem> = ShallowUnwrapRef<Instance<T>>;
}
vue
<script lang="ts" setup generic="T extends BaseItem">
import { VirtualScroller } from './VirtualScroller';
import type { BaseItem } from './VirtualScroller.types';
import VirtualScrollerItem from './VirtualScrollerItem.vue';

// The namespace carries the whole contract (props types + defaults merged
// by propsWithDefaults, emits, slots, expose type) — the macros receive
// RUNTIME objects, so the compiler never resolves a cross-file type here;
// the casts recover the generic <T> precision the runtime maps cannot carry.
const props = defineProps(
  VirtualScroller.props
) as unknown as VirtualScroller.Props<T>;

const emit = defineEmits(VirtualScroller.emits) as VirtualScroller.Emits;

defineSlots<VirtualScroller.Slots<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 {
  // state refs
  scrollbarDragging,
  // 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 size. 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 size 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 sizes. -->
      <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="(size) => virtualScroller.syncItemSize(element.index, size)"
        >
          <slot name="item" v-bind="element"></slot>
        </VirtualScrollerItem>
      </div>
      <div :style="{ height: virtualScroller.trailingSpacerPx }"></div>
    </div>
    <div
      v-if="virtualScroller.scrollbarVisible"
      class="virtual-scroller__track"
      @pointerdown="virtualScroller.onTrackPointerDown"
      @pointermove="virtualScroller.onTrackPointerMove"
      @pointerup="virtualScroller.onTrackPointerUp"
      @pointercancel="virtualScroller.onTrackPointerUp"
    >
      <div
        class="virtual-scroller__thumb"
        :class="{ dragging: scrollbarDragging }"
        :style="virtualScroller.scrollbarThumbStyle"
      ></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__track {
  position: absolute;
  top: 10px;
  bottom: 10px;
  right: 6px;
  width: 12px;
  cursor: pointer;
  touch-action: none;
  z-index: 1;
}
.virtual-scroller__track::before {
  content: '';
  position: absolute;
  inset: 0 4px;
  border-radius: 999px;
  background: rgba(148, 163, 184, 0.18);
}
.virtual-scroller__thumb {
  position: absolute;
  left: 2px;
  right: 2px;
  border-radius: 999px;
  background: rgba(148, 163, 184, 0.45);
  /* eased relocation: a fast flick or loop-wrap moves the thumb far in
     one frame — glide it instead of teleporting */
  transition: background 0.15s ease, top 0.2s ease-out, height 0.2s ease-out;
}
.virtual-scroller__thumb.dragging {
  /* while the finger owns it, the thumb must stick — no easing lag */
  transition: background 0.15s ease;
}
.virtual-scroller__track:hover .virtual-scroller__thumb,
.virtual-scroller__thumb.dragging {
  background: rgba(148, 163, 184, 0.75);
}
.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;
  /** Main axis the parent scroller virtualizes ('y' default). */
  axis?: 'y' | 'x';
}

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

const props = withDefaults(defineProps<VirtualScrollerItem>(), { axis: 'y' });

const emit = defineEmits<VirtualScrollItemEmits>();

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

/**
 * ONE-SHOT size capture — deliberately not a ResizeObserver. Items render
 * in normal flow, so the browser positions them at their real size with no
 * bookkeeping; the parent only needs sizes 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 size — 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
  // size 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 horizontal = props.axis === 'x';
  const parentLayout = horizontal
    ? (parent?.offsetWidth ?? 0)
    : (parent?.offsetHeight ?? 0);
  const parentRect = parent
    ? horizontal
      ? parent.getBoundingClientRect().width
      : parent.getBoundingClientRect().height
    : 0;
  const scale = parent && parentLayout > 0 ? parentRect / parentLayout : 1;
  const rect = el.getBoundingClientRect();
  const size = horizontal ? rect.width : rect.height;
  emit('sizeUpdated', scale > 0 ? size / scale : size);
};

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>
ts
import { ref, shallowRef } from 'vue';
import { Reactive } from '../../../../../lib/Reactive';
import type { HorizontalVirtualScroller } from '../../../../../examples/playground/src/examples/virtual-scroller/HorizontalVirtualScroller';
import type { BaseItem } from '../../../../../examples/playground/src/examples/virtual-scroller/VirtualScroller.types';

/**
 * The docs example model for the horizontal 1M example — itself written to
 * the standard the page teaches: one class, one namespace carrying the
 * class plus every const the module owns (non-exported members stay
 * private to the file), nothing at module level beside imports.
 */
class $HorizontalScrollerExample {
  // MUTABLE STATE — the list is replaced wholesale, never deep-mutated,
  // so shallowRef keeps a million cards out of the deep-proxy machinery.
  get items() {
    return shallowRef<BaseItem[]>(HorizontalScrollerExample.buildItems());
  }

  // MUTABLE STATE — the glide-speed slider writes this (px/s).
  get speed() {
    return ref(50);
  }

  // TEMPLATE-REF TARGET — the scroller component's exposed instance.
  get scroller() {
    return ref<HorizontalVirtualScroller.Exposed<BaseItem> | null>(null);
  }

  /* DERIVED — plain getters; reactive through the scroller's expose. */

  get renderedCount() {
    return this.scroller.value?.visibleItems.length ?? 0;
  }

  get creepMsPerPx() {
    return 1000 / Math.max(1, this.speed.value);
  }

  get speedLabel() {
    return `${this.speed.value} px/s`;
  }

  get isAutoPlaying() {
    return this.scroller.value?.isAutoPlaying ?? false;
  }

  get playButtonIcon() {
    return this.isAutoPlaying ? '⏸' : '▶';
  }

  get playButtonLabel() {
    return this.isAutoPlaying ? 'pause the glide' : 'glide';
  }

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

  toggleAutoPlay() {
    const scroller = this.scroller.value;
    if (!scroller) return;
    if (this.isAutoPlaying) {
      scroller.stopAutoPlay();
    } else {
      scroller.startAutoPlay(0);
    }
  }
}

export namespace HorizontalScrollerExample {
  /* Identity */

  export const $Class = $HorizontalScrollerExample; // raw — children `extends` this
  export let Class = Reactive($Class); // reactive — you `new` this
  export type Instance = typeof Class.Instance; // defineExpose type & reactive() interop

  /* Values */

  export const ITEM_COUNT = 1_000_000;

  // Non-exported namespace members: private to this file — the namespace
  // is the ONE seam, so nothing lives at module level beside it.
  const CAPTIONS = [
    'The window walks; the strip stands still',
    'A million cards, a handful of divs',
    'Widths are captured once, in and out',
    'Estimates decide the spacers',
    'Scroll is virtual — the DOM never learns the total',
    'Everything costs O(window)',
  ];

  export function buildItems(): BaseItem[] {
    const items = new Array(ITEM_COUNT);
    for (let index = 0; index < ITEM_COUNT; index++) {
      items[index] = {
        id: String(index),
        body: CAPTIONS[(index * 7) % CAPTIONS.length],
        position: String(index + 1),
      };
    }
    return items;
  }
}
vue
<script setup lang="ts">
/**
 * The HorizontalVirtualScroller example, live in the docs. The scroller
 * is the vertical class rotated through its axis seams — the same tuned
 * physics, running sideways over a million cards. Only this wrapper (the
 * data and the chrome) is docs code, and its model lives in
 * HorizontalScrollerExample.ts, written to the same standard the page
 * teaches.
 */
import DemoBox from '../DemoBox.vue';
import HorizontalVirtualScroller from '../../../../../examples/playground/src/examples/virtual-scroller/HorizontalVirtualScroller.vue';
import { HorizontalScrollerExample } from './HorizontalScrollerExample';

const example = new HorizontalScrollerExample.Class();

// the state destructure — every Ref the template touches, grouped
const {
  // state refs
  items,
  speed,
  // element refs
  scroller,
} = example;
</script>

<template>
  <DemoBox
    title="Horizontal scroller — 1,000,000 cards, the vertical class sideways"
    note="The same production scroller class, extended: eight overridden axis seams turn translateY into translateX, heights into widths, deltaY into deltaX. Cursor math, origin rebasing, the creep integrator and the seek pipeline run unchanged. Shift+wheel or swipe drives the strip; a plain vertical wheel scrolls this page; the bar below the cards drags in progress space."
  >
    <div class="d-vals ehs-stats">
      <div>
        <div class="d-k">cards in the list</div>
        <div class="d-n">{{ HorizontalScrollerExample.ITEM_COUNT.toLocaleString() }}</div>
      </div>
      <div>
        <div class="d-k">cards in the DOM</div>
        <div class="d-n grad">{{ example.renderedCount }}</div>
      </div>
    </div>

    <div class="ehs-frame">
      <HorizontalVirtualScroller
        ref="scroller"
        v-model="items"
        scrollbar
        :assumed-size="230"
        :padding-quantity="8"
        :creep-ms-per-px="example.creepMsPerPx"
        auto-play
        :auto-play-delay="800"
      >
        <template #item="{ item }">
          <div class="ehs-card">
            <b>#{{ Number(item.position).toLocaleString() }}</b>
            <span>{{ item.body }}</span>
          </div>
        </template>
      </HorizontalVirtualScroller>
    </div>

    <div class="d-row">
      <button class="d-btn primary" type="button" @click="example.jumpTo(499999)">
        jump to #500,000
      </button>
      <button class="d-btn" type="button" @click="example.jumpTo(HorizontalScrollerExample.ITEM_COUNT - 1)">
        jump to the end
      </button>
      <button class="d-btn" type="button" @click="example.jumpTo(0)">
        back to the start
      </button>
      <button
        class="d-btn"
        :class="{ 'ehs-playing': example.isAutoPlaying }"
        type="button"
        @click="example.toggleAutoPlay()"
      >
        <span class="ehs-btn-icon">{{ example.playButtonIcon }}</span>
        {{ example.playButtonLabel }}
      </button>
      <label class="ehs-speed">
        speed
        <input
          v-model.number="speed"
          type="range"
          min="10"
          max="600"
          step="10"
        />
        <span class="ehs-speed-value">{{ example.speedLabel }}</span>
      </label>
    </div>
  </DemoBox>
</template>

<style scoped>
.ehs-stats {
  margin-bottom: 14px;
}
.ehs-frame {
  border: 1px solid rgba(148, 163, 184, 0.16);
  border-radius: 10px;
  background: rgba(255, 255, 255, 0.02);
  overflow: hidden;
  margin-bottom: 14px;
}
.ehs-frame :deep(.virtual-scroller--x) {
  padding: 16px 16px 26px; /* main-axis 16px counts toward the extent;
                              the bottom band hosts the built-in track */
}
.ehs-card {
  display: flex;
  flex-direction: column;
  gap: 6px;
  margin-right: 12px; /* the gap between cards IS card width — measured */
  padding: 18px 20px;
  border: 1px solid rgba(148, 163, 184, 0.22);
  border-radius: 10px;
  background: rgba(148, 163, 184, 0.06);
  white-space: nowrap; /* natural width per caption — every card differs */
  font-size: 13px;
  line-height: 1.5;
  color: var(--vp-c-text-2);
}
.ehs-card :deep(b),
.ehs-card b {
  color: #7dd3fc;
  font-weight: 700;
  font-size: 15px;
}

.ehs-btn-icon {
  margin-right: 6px;
}
.d-btn.ehs-playing {
  border-color: rgba(52, 211, 153, 0.6);
  background: rgba(52, 211, 153, 0.1);
  color: #34d399;
}
.ehs-speed {
  display: flex;
  align-items: center;
  gap: 8px;
  font-size: 12.5px;
  color: var(--vp-c-text-2);
}
.ehs-speed input {
  width: 150px;
  accent-color: #6366f1;
}
.ehs-speed-value {
  min-width: 58px;
  color: var(--vp-c-text-1);
  font-variant-numeric: tabular-nums;
}
</style>

The example model is docs code — and it is written to the same standard the page teaches: one class, one namespace, the million-card builder and its caption bank living inside the namespace (the captions un-exported, private to the file).

What to notice

  • Cards in the DOM stays at the window size across a million cards — and every card has a different natural width (the caption decides it), so the width map is genuinely measured, not assumed.
  • A plain vertical wheel scrolls the page. The strip obeys deltaX only — shift+wheel and horizontal trackpad swipes drive it. A horizontal element that hijacks vertical scrolling is how you make readers hate a page.
  • The bar under the cards drags in progress space — the thumb renders position / (extent − container), and its drag is the exact inverse, so it lands where it points, including the true end.
  • The glide hands off, both ways. A forward flick decays to the glide speed and the creep adopts the scroll at that exact speed — it never stalls to zero and restarts. The slider changes speed mid-glide; the integrator reads the live value every frame.

Wheel through a million cards above, then read the two files that made it possible — that ratio is the page's whole argument.

Released under the MIT License.