zero-scroll
v0.1.0
Published
Virtual scroll engine with zero DOM measurement. Fenwick tree + composable height providers.
Maintainers
Readme
zero-scroll
Virtual scroll engine with zero DOM measurement in the scroll hot path.
Uses a Fenwick tree (Binary Indexed Tree) for O(log n) offset lookups. Pre-compute item heights once, then every scroll frame is pure arithmetic - no offsetHeight, no getBoundingClientRect, no layout thrashing.
Performance
| Items | Per scroll frame | Setup (one-time) | |-------|-----------------|-------------------| | 1,000 | 0.004ms | 0.9ms | | 100,000 | 0.001ms | 1.7ms | | 1,000,000 | 0.0003ms | 21ms |
Compared to DOM measurement at 1,000 items:
- 43x faster than DOM batch (write all, read all)
- 114x faster than DOM interleaved (layout thrashing)
- 103x faster per-scroll than TanStack-style estimate+measure
Install
npm install zero-scrollQuick start
Framework-agnostic (core)
import { PrefixSum, computeWindow, FixedHeightProvider } from 'zero-scroll'
// 1. Create Fenwick tree and populate heights
const prefixSum = new PrefixSum(10000)
for (let i = 0; i < 10000; i++) {
prefixSum.update(i, itemHeights[i])
}
// 2. On every scroll event - pure math, zero DOM
const { items, totalHeight } = computeWindow(
scrollTop, // current scroll position
viewportHeight, // container height
prefixSum,
10000,
3 // overscan
)
// 3. Render only visible items
items.forEach(({ index, offset, height }) => {
// position item at offset with given height
})React
import { useVirtualScroll, FixedHeightProvider } from 'zero-scroll/react'
function MyList({ data }) {
const scrollRef = useRef(null)
const provider = useMemo(() => new FixedHeightProvider(50), [])
const { items, totalHeight } = useVirtualScroll({
count: data.length,
heightProvider: provider,
scrollRef,
})
return (
<div ref={scrollRef} style={{ height: 600, overflow: 'auto' }}>
<div style={{ height: totalHeight, position: 'relative' }}>
{items.map(item => (
<div key={item.index} style={{
position: 'absolute',
transform: `translateY(${item.offset}px)`,
height: item.height,
}}>
{data[item.index]}
</div>
))}
</div>
</div>
)
}With <VirtualList> component
import { VirtualList, FixedHeightProvider } from 'zero-scroll/react'
const provider = new FixedHeightProvider(50)
<VirtualList
items={data}
heightProvider={provider}
renderItem={(item, index) => <div>{item.text}</div>}
style={{ height: 600 }}
/>Height providers
Heights must be known upfront. This is the core design constraint that enables zero-DOM scrolling.
FixedHeightProvider
All items have the same height.
const provider = new FixedHeightProvider(50)TextHeightProvider
Uses pretext for pure-math text measurement. Pretext is an optional peer dependency - inject it, don't import it.
import { TextHeightProvider } from 'zero-scroll'
import * as pretext from '@chenglou/pretext'
const provider = new TextHeightProvider(
(index) => ({
text: items[index].text,
font: '16px Inter',
lineHeight: 20,
maxWidth: containerWidth,
padding: { top: 8, bottom: 8 },
}),
pretext // injected, not imported by zero-scroll
)CompositeHeightProvider
Mix different providers for different item types (e.g., text messages + image cards + date separators).
import { CompositeHeightProvider, FixedHeightProvider, TextHeightProvider } from 'zero-scroll'
const textProvider = new TextHeightProvider(getDescriptor, pretext)
const separatorProvider = new FixedHeightProvider(32)
const provider = new CompositeHeightProvider(
(index) => items[index].type === 'separator' ? separatorProvider : textProvider,
[textProvider, separatorProvider] // all sub-providers for invalidateAll()
)Custom provider
Implement the HeightProvider interface for any height calculation logic.
const provider = {
getHeight(index) { return myHeightCalculation(index) },
invalidate(index) { /* clear cache for index */ return true },
invalidateAll() { /* clear all caches */ },
}Scroll anchoring
When item heights change above the viewport, the scroll position is automatically adjusted to prevent visual jumping.
import { captureAnchor, restoreAnchor } from 'zero-scroll'
// Before height change
const anchor = captureAnchor(scrollTop, prefixSum)
// ... update heights ...
prefixSum.update(index, newHeight)
// Restore scroll position
scrollContainer.scrollTop = restoreAnchor(anchor, prefixSum)API
Core
| Export | Description |
|--------|-------------|
| PrefixSum | Fenwick tree for O(log n) offset queries and updates |
| computeWindow() | Pure-math visible item calculation |
| HeightCache | Syncs height providers with PrefixSum |
| captureAnchor() / restoreAnchor() | Scroll position stabilization |
Providers
| Export | Description |
|--------|-------------|
| FixedHeightProvider | Constant height for all items |
| TextHeightProvider | Pretext-based text measurement |
| CompositeHeightProvider | Mix multiple providers |
React (zero-scroll/react)
| Export | Description |
|--------|-------------|
| useVirtualScroll() | Hook with scroll sync, anchoring, invalidation |
| VirtualList | Convenience component with ref API |
Design philosophy
- No DOM measurement fallback. You must know heights upfront. This constraint is what makes zero-DOM scrolling possible.
- Pretext is optional. Core has zero dependencies. React is an optional peer dep.
- O(log n) everything. Fenwick tree gives O(log n) for both queries and updates.
- React re-renders only when visible range changes, not on every scroll pixel.
Inspired by
chenglou/pretext - Pure TypeScript text measurement that bypasses DOM reflow. Zero-scroll applies the same "measure once, math forever" philosophy to virtual scrolling.
License
MIT
