@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
pnpm add @studnicky/visible-rangeUsage
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():
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:
import { VisibleRange } from '@studnicky/visible-range';
const range = VisibleRange.builder()
.withCount(1000)
.withItemSize(40)
.withOverscan(2)
.build();| Method | Description |
|---|---|
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(): VisibleRange | Constructs 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():
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
itemSizenorestimateSizeis supplied, - both
itemSizeandestimateSizeare 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.
| Hook | When it fires | Args |
|---|---|---|
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.