Skip to content

Dharmesh2015/dscroll

Repository files navigation

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/removeChild cost 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 dscroll

Or drop the folder in directly (no build required):

cp -r path/to/dscroll/ ./vendor/dscroll
import 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 view

Instance methods (Stable since 1.2)

instance.extend(namespace, methods)          // sanctioned plugin namespace channel
instance.scheduleAfter(ms, fn)  handle      // destroy-coordinated setTimeout

See 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:

  1. Hooks called in registration order.
  2. Errors caught — one plugin's bug never breaks another or core.
  3. extend() is the only sanctioned channel for namespace mutation; scheduleAfter() is the only sanctioned channel for plugin timers (auto-cancelled in destroy()).
  4. 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 test

Contributing

PRs welcome. Every change must:

  1. Add a CHANGELOG.md entry.
  2. Update API.md if public surface changes.
  3. Pass npm test (all 301 tests).
  4. 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.

About

No description, website, or topics provided.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors