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 { VisibleRangeEntity } from '../src/entities/index.js';

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

const fixedModeChanges: VisibleRangeEntity.Type[] = [];

class TelemetryVisibleRange extends VisibleRange {
  protected override onRangeChange(range: VisibleRangeEntity.Type): void {
    console.log(`[visible-range] range changed to [${String(range.start)}, ${String(range.end)}]`);
    fixedModeChanges.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 });

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:', fixedModeChanges);

// 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 estimatedItemSize = 16 * 2;
    return estimatedItemSize;
  },
  '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);

Try it

Loading example…

The output shows fixed-mode onRangeChange firing only when the computed range actually moves (not for an identical re-set scroll offset), and variable-mode range estimates shifting once measureItem() corrects the per-index size estimate with real measurements.

Construction

VisibleRange.create({ count, itemSize, overscan? }) selects fixed-size arithmetic. VisibleRange.create({ count, estimateSize, overscan? }) selects variable-size arithmetic. Exactly one sizing strategy is required.

Errors

VisibleRangeError is the root-exported package error thrown when VisibleRange.create() receives invalid or ambiguous config:

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 preceding range. The first call always fires.range: VisibleRangeEntity.Type

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

Import VisibleRange, VisibleRangeEntity, VisibleRangeConfigInterface, and VisibleRangeError from @studnicky/visible-range. The package declares separate root, entity, and interface import surfaces.

Source on GitHub

Entities

@studnicky/visible-range/entities exports every schema namespace in src/entities.

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

Interfaces

@studnicky/visible-range/interfaces exports every TypeScript interface in src/interfaces, including configuration and state contracts.

typescript
import type { VisibleRangeConfigInterface } from '@studnicky/visible-range/interfaces';

Exports

SymbolPurposeImport path
VisibleRangeProvides visible range functionality.@studnicky/visible-range
VisibleRangeErrorRepresents visible range failures.@studnicky/visible-range