Skip to content

@studnicky/visible-range

Pure index/offset arithmetic for computing the visible item range of a virtualized list.

Zero DOM dependency — this package never references window, document, or ResizeObserver. The caller wires actual scroll-event listeners and ResizeObserver themselves, and feeds the results in via setScrollOffset() / setViewportSize().

Install

bash
pnpm add @studnicky/visible-range

Usage

Given a scroll offset, a viewport size, an item-size accessor (fixed or per-index), and an overscan count, VisibleRange computes the inclusive [start, end] index range of items currently visible. Fixed mode (itemSize) shares one size across every item; variable mode (estimateSize) uses a per-index estimator corrected over time via measureItem():

ts
import type { VisibleRangeType } from '../src/index.js';

import { VisibleRange } from '../src/index.js';

class TelemetryVisibleRange extends VisibleRange {
  readonly changes: VisibleRangeType[] = [];

  protected override onRangeChange(range: VisibleRangeType): void {
    console.log(`[visible-range] range changed to [${String(range.start)}, ${String(range.end)}]`);
    this.changes.push(range);
  }
}

// Fixed mode: every row is 40px tall, no DOM — the caller supplies scroll
// offset and viewport size from its own scroll-event/ResizeObserver wiring.
const rows = TelemetryVisibleRange.create({ 'count': 10_000, 'itemSize': 40, 'overscan': 2 }) as TelemetryVisibleRange;

rows.setViewportSize(400);
rows.setScrollOffset(0);
rows.getRange(); // fires — first call always "changes"

rows.setScrollOffset(0);
rows.getRange(); // no fire — identical range

rows.setScrollOffset(2000);
rows.getRange(); // fires — scrolled past the previous window

console.log('Fixed-mode ranges:', rows.changes);

// Variable mode: rows have an estimated size, corrected as real
// measurements arrive (e.g. after a row renders and reports its height).
const list = VisibleRange.create({
  'count': 500,
  'estimateSize': () => { const result = 32; return result; },
  'overscan': 1
});

list.setViewportSize(200);
list.setScrollOffset(320);

const estimated = list.getRange();

// A caller measuring actual rendered heights corrects the estimate.
for (let i = 0; i < 10; i++) {
  list.measureItem(i, 16);
}

const corrected = list.getRange();

console.log('Variable-mode range (estimated):', estimated);
console.log('Variable-mode range (after measureItem corrections):', corrected);

Builder

VisibleRange.builder() returns a VisibleRangeBuilder — a fluent alternative to VisibleRange.create() for callers assembling config incrementally:

typescript
import { VisibleRange } from '@studnicky/visible-range';

const range = VisibleRange.builder()
  .withCount(1000)
  .withItemSize(40)
  .withOverscan(2)
  .build();
MethodDescription
withCount(value)Total item count.
withItemSize(value)Fixed size shared by every item. Mutually exclusive with withEstimateSize().
withEstimateSize(value)Per-index size estimator. Mutually exclusive with withItemSize().
withOverscan(value)Extra items included on either side of the visible range.
build(): VisibleRangeConstructs the VisibleRange instance.

build() applies the same validation as VisibleRange.create() — supplying neither withItemSize()/withEstimateSize(), or supplying both, throws a VisibleRangeError.

Errors

VisibleRangeError (from @studnicky/errors' BaseError) is thrown when a config is invalid or ambiguous, both via VisibleRange.create() and VisibleRangeBuilder.build():

typescript
import { VisibleRange, VisibleRangeError } from '@studnicky/visible-range';

try {
  VisibleRange.create({ count: 100 }); // neither itemSize nor estimateSize supplied
} catch (error) {
  if (error instanceof VisibleRangeError) {
    console.error(error.code); // 'visibleRange.invalidConfig'
  }
}

It carries a fixed code of 'visibleRange.invalidConfig' and retryable: false. It is thrown when:

  • neither itemSize nor estimateSize is supplied,
  • both itemSize and estimateSize are supplied.

Observability hooks

Subclass VisibleRange and override the protected hook to inject trace logging, metrics, or side-effects at the exact stage where they are needed. Hooks should stay fast and non-blocking; observer-hook failures are contained so range computation still wins.

HookWhen it firesArgs
onRangeChange(range)At the end of getRange(), only when the computed range differs from the previously computed range. The first call always fires.range: VisibleRangeType

The base class never calls any logger or metrics library. All hooks are no-ops by default.

Source on GitHub