dscroll
v1.2.0
Published
Slim, fast, vanilla-JS virtual-scrolling library for fixed-height row lists. Real DOM-node recycling pool, direction-biased buffer with velocity-aware scaling, plugin architecture. Zero dependencies.
Maintainers
Readme
DScroll
A slim, fast, vanilla-JS virtual-scrolling library for fixed-height row lists. Zero dependencies. Plugin-extensible. Real DOM-node recycling pool.
Author: Dharmesh Patel · License: MIT · Version: 1.2.0
DScroll renders only the rows currently visible in a scroll container, plus a small buffer ahead and behind. It's intentionally narrow: fixed-height rows, single-axis (vertical), no framework adapters. Variable heights, multi-column layouts, etc. land later as plugins or in V2.
What makes DScroll different
- Real DOM-node recycling pool — most vanilla peers destroy + recreate row
nodes on every range crossing. DScroll keeps a fixed pool and rebinds
existing nodes, paying zero
appendChild/removeChildcost during scroll. - Direction-biased buffer with velocity-aware scaling and inertia projection — most peers use a fixed symmetric overscan. DScroll renders more rows ahead of motion direction, scales the buffer with scroll velocity, and projects 4 frames forward under inertia (mitigates iOS Safari's blank-on-momentum issue).
- Plugin architecture where every V2+ feature lands as a plugin without
touching core (
extend()+scheduleAfter()primitives). - Zero allocations in the hot scroll path (steady state).
- ~605 LOC core across
dmath.js+dpool.js+dscroll.js.
Where it's used
DScroll powers the project task panel virtualization in DPlan, where it scrolls many-thousand-task plans on desktop trackpads at 60 fps with a fixed pool of ~225 row nodes.
This package is the standalone extraction of that library — same code, same contract, available for any project that needs vanilla virtual scrolling.
Install
npm install dscrollOr drop the folder in directly (no build required):
cp -r path/to/dscroll/ ./vendor/dscrollimport DScroll from 'dscroll';
// Or with the vendor copy:
import DScroll from './vendor/dscroll/dscroll.js';For TypeScript users, declarations ship as dscroll.d.ts alongside.
Quick start
<div id="my-list-scroll" style="height: 600px; overflow-y: auto">
<div id="my-list-rows" style="position: relative"></div>
</div>
<script type="module">
import DScroll from 'dscroll';
const data = [/* …5000 task objects… */];
const ds = DScroll.create({
scrollContainer: '#my-list-scroll',
rowContainer: '#my-list-rows',
rowHeight: 28,
renderRow(node, task, ctx) {
// Mutate node in place. Called when node binds to new data.
node.dataset.id = task.id;
node.textContent = task.name;
node.className = 'row' + (task.urgent ? ' urgent' : '');
},
});
ds.setData(data);
</script>That's it. The library handles the rest.
Public API
Creation
DScroll.create(options) → instance
| Option | Type | Required | Description |
|---|---|---|---|
| scrollContainer | string \| Element | ✓ | Element with overflow-y: scroll. CSS selector or DOM node. |
| rowContainer | string \| Element | ✓ | Element inside scrollContainer that holds rows. Must be position: relative. |
| rowHeight | number | ✓ | Pixel height of every row. Fixed; does not vary. |
| renderRow | (node, data, ctx) => void | ✓ | Mutate node in place when it binds to data. |
| poolSize | number | optional | Auto-computed from viewport + buffer. Override for predictable memory. |
| buffer | { fwd, back, base } | optional | Default { 250, 30, 75 }. Smaller for memory-constrained scenarios. |
| plugins | Plugin[] | optional | Plugin instances; see Plugins below. |
| devMode | boolean | optional | Asserts invariants on every operation; throws on contract violation. |
| onError | (err, ctx) => void | optional | Error handler for plugin/render errors. |
Instance methods (Stable since 1.0)
instance.setData(data) // replace underlying data; preserves scroll
instance.invalidate(dataId) // re-render a single row (O(1))
instance.scrollToIndex(idx, { align, behavior })
instance.destroy() // teardown; idempotent
instance.on(event, handler) → unsubscribe
instance.off(event, handler)
instance.isScrolling() // boolean
instance.getRange() → { start, end }
instance.getPool() // beta — read-only pool viewInstance methods (Stable since 1.2)
instance.extend(namespace, methods) // sanctioned plugin namespace channel
instance.scheduleAfter(ms, fn) → handle // destroy-coordinated setTimeoutSee API.md for full signatures, complexity, and stability guarantees.
Events
| Event | Payload | When fired |
|---|---|---|
| init | { instance } | After create() returns |
| bind | { node, data, ctx } | A pool node was bound to new data |
| unbind | { node } | A pool node was released |
| scroll | { scrollTop, direction, velocity, isScrolling } | After scroll handler processes an event |
| range | { start, end, prevStart, prevEnd } | Visible range changed |
| resize | { width, height } | Container resized |
| render | { count, durationMs } | Render cycle completed |
| destroy | {} | Before instance teardown |
Plugins
Plugins are how V2+ features land without modifying core. A plugin is a function returning a hook object:
function MyPlugin(opts = {}) {
return {
name: 'my-plugin',
onInit(instance) { /* setup; call instance.extend('myns', {...}) here */ },
onBind(node, data, ctx) { /* row got new data */ },
onUnbind(node) { /* row released */ },
onScroll(state) { /* scrolled */ },
onResize(state) { /* viewport resized */ },
onRender(range) { /* visible range changed */ },
onDestroy() { /* teardown — timers via scheduleAfter auto-cancel */ },
};
}All hooks optional. Pass via plugins option.
Built-in plugins
| Plugin | Status | Purpose |
|---|---|---|
| flash | V1.2 shipped | Pool-aware row flash. Reference plugin shape. See plugins/flash/. |
| scrollRestore | V1.3 planned | Persist + restore scroll position via localStorage. |
| keyboardNav | V1.3 planned | Arrow-key navigation between rows. |
| stickyGroups | V2.0 planned | Sticky group headers. |
| animation | V2.0 planned | Row insert / remove / move animations. |
Plugin contract guarantees:
- Hooks called in registration order.
- Errors caught — one plugin's bug never breaks another or core.
extend()is the only sanctioned channel for namespace mutation;scheduleAfter()is the only sanctioned channel for plugin timers (auto-cancelled indestroy()).- Plugins observe — they don't intercept.
Full contract in ARCHITECTURE.md §4.2.
Performance & capacity
Tested operating envelope (Chrome 147 / macOS Intel, 5× median):
| Tier | Rows | Cold render (typ.) | p99 frame (typ.) | Sustained scroll | |---|---|---|---|---| | Sweet spot | 0 – 10,000 | 14–17 ms | 17.6 ms | 60 fps | | Designed-for | 10k – 100,000 | 14–16 ms | 17.6 ms | 60 fps | | Stretch | 100k – 500,000 | 14–25 ms | 17.6–67 ms | 49–60 fps |
Hot-path contracts (asserted by Node bench in CI):
- Scroll handler ≤ 4 ms p99 at every tier
- Per-flash-call ≤ 0.05 ms p50 (when flash plugin is loaded)
- Heap delta ≤ 100 KB after 1000 scroll events
See BENCHMARKS.md for full head-to-head measurement against TanStack Virtual and virtua, including methodology and disclaimers.
Browser support
- Chrome / Edge: 90+
- Firefox: 88+
- Safari: 14+ (modern WebKit)
- Mobile Safari iOS: 14+
- Mobile Chrome Android: 90+
Requires native ES module import, ResizeObserver, requestAnimationFrame,
and CSS transform: translate3d. No polyfills shipped.
Documents in this folder
| File | Purpose |
|---|---|
| README.md (this) | Quickstart + API surface |
| ARCHITECTURE.md | Design rationale, SOLID mapping, plugin contract |
| API.md | Function-level registry (every public + internal fn) |
| CONTRACTS.md | Invariants + capacity envelope |
| CHANGELOG.md | Version history (Keep-a-Changelog format) |
| BENCHMARKS.md | Performance baselines + head-to-head with peers |
| PRIOR_ART.md | Library research + failure modes inherited from peers |
| FUTURE_IDEAS.md | Backlog of deferred features with triggers |
| DscrollFlash.md | V1.2 flash plugin spec (Round Table locked) |
| LICENSE | MIT |
| NOTICE.md | Third-party notices (none for runtime; peer benches only) |
Running tests + benchmarks
npm test # 301 tests across 5 files
npm run bench # Node-based flash plugin perf gates
# Browser-based benches (require local HTTP server, not file://)
python3 -m http.server 8080
# open http://localhost:8080/bench/peers.html # 5-library × 5-tier head-to-head
# open http://localhost:8080/bench/run.html # capacity sweep
# open http://localhost:8080/bench/v1-vs-v2.html # row-renderer micro-bench
# open http://localhost:8080/stress.html # interactive stress testContributing
PRs welcome. Every change must:
- Add a
CHANGELOG.mdentry. - Update
API.mdif public surface changes. - Pass
npm test(all 301 tests). - Not regress
npm run bench(5 perf gates).
Public-API changes require a deprecation period. Internal API (_prefix)
changes free.
License
MIT. Copyright (c) 2026 Dharmesh Patel. See LICENSE.
