@zakkster/lite-virtual
v1.1.0
Published
Thrash-free list/grid windowing on @zakkster/lite-signal. Integer-gated reactive indices + Object.is cutoff = scrolling within a row writes zero bytes to the DOM. ~3.6M sub-row scrolls/sec, bounded pool regardless of count, fixed/variable/measured auto-he
Maintainers
Keywords
Readme
@zakkster/lite-virtual
Thrash-free list/grid windowing on
@zakkster/lite-signal. Integer-gated reactive indices + Object.is cutoff = scrolling within a row writes nothing to the DOM and allocates zero bytes.
npm i @zakkster/lite-virtual @zakkster/lite-signalimport { mount } from "@zakkster/lite-element";
import { mountList } from "@zakkster/lite-virtual";
mount(document.getElementById("box"), (host, scope) => {
mountList(host, scope, {
count: 1_000_000, // a million rows
itemHeight: 32,
viewport: host.clientHeight,
render: (rowEl, i) => { rowEl.textContent = `row ${i}`; },
});
});Headline (measured on Node 22, see Benchmarks):
~3.6 million sub-row scroll events per second at 0 bytes/op.
A mountList with 1,000,000 items keeps ~21 DOM nodes alive.
~800K full scroll events/sec on a 1M-item list. Scrolling within
one row writes literally nothing to the DOM and allocates literally
zero bytes -- verified by the test suite, not by hope.
Table of contents
- Why this exists
- What you get
- Quickstart
- Pure-math axes
- Recycling renderers
- Stateful rows: when to use
mountKeyedList - Variable heights
- Observer safety
- API reference
- Benchmarks
- Edge cases pinned down
- What this is not
- Browser / runtime support
- Peer dependency
- FAQ
- License
Why this exists
A small set of design constraints picked deliberately:
- Boundary-only updates. The visible range derives from
Math.floor(scrollTop / itemSize)-- an integer. lite-signal'sObject.iscutoff means that integer only changes when you actually cross a row boundary. A fast 1-pixel scroll inside one row produces ZERO reactive work, ZERO DOM writes, ZERO bytes allocated. The whole chain -- start, end, offsetStart, totalSize, your bind effects -- sits idle between crossings. - Bounded DOM pool. A
mountListof 1,000,000 items keeps ~21 DOM nodes alive, no matter how far you scroll. Recycling is index-based: on a one-row scroll, exactly one node's transform + content updates. - No virtual DOM. No template compiler. No renderer. The math
(
virtualAxis,virtualGrid,variableAxis) is pure reactive state you can pair with any drawing primitive -- DOM, canvas, WebGL, terminal ANSI. The included recycling renderers are opinionated, ~1 KB each, and use lite-element's scope. - Observer-safe by design. Each renderer sets
host.style.overflow = "auto", so the giant inner spacer scrolls INSIDE the host and never inflates the host's reported size to outer observers (IntersectionObserver, parent ResizeObserver, flex containers). The library's own RO readscontentRect.height-- neverscrollHeight-- so no feedback loop is reachable. - Two layers, separated. Pure math axes (1 KB) are useful on their own. Recycling renderers (3 KB total) layer on top. Use what you need, skip what you don't.
If you want a framework integration with hooks, a <VirtualList> component,
or SSR helpers -- this is the wrong library. (Sticky headers and infinite
loading are covered headlessly by stickyHeader / onEndReached.) Eleven
functions, one peer dep, no runtime deps.
What you get
// Pure math -- pair with any renderer:
const axis = virtualAxis({ count, itemSize, viewport, overscan });
axis.start(); // reactive integer -- only changes on boundary cross
axis.end(); // reactive integer
axis.offsetStart(); // reactive px
axis.totalSize(); // reactive px (count * itemSize)
axis.setScroll(px); axis.setViewport(px); axis.setCount(n);
const grid = virtualGrid({ rowCount, colCount, rowHeight, colWidth,
viewportHeight, viewportWidth, overscan });
grid.rowStart, grid.rowEnd, grid.colStart, grid.colEnd, /* ... */
const va = variableAxis({ count, sizeAt: i => sizes[i], viewport, overscan });
va.positionAt(i); // O(1) prefix-sum offset
va.minSize(); // smallest row (for pool pre-sizing)
va.remeasure(); // rebuild offsets from current sizeAt
va.offsetForIndex(i, "center"); // clamped scroll-to offset (every axis)
// Auto-height: sizes unknown until rendered (Fenwick-backed, O(log n) patch):
const ma = measuredAxis({ count, estimateSize, viewport, overscan });
ma.measure(i, realPx); // report a row's measured height
// Opinionated recycling renderers -- built on lite-element's scope shape:
mountList(host, scope, { count, itemHeight, viewport, render }); // + horizontal?
mountKeyedList(host, scope, { items, itemHeight, viewport, key, render });
mountGrid(host, scope, { rowCount, colCount, rowHeight, colWidth,
viewportWidth, viewportHeight, render });
mountVariableList(host, scope, { count, sizeAt, viewport, render });
mountMeasuredList(host, scope, { count, estimateSize, viewport, render });
// renderers attach axis.scrollToIndex(i, align?) / grid.scrollToCell(r, c, align?)
// Headless helpers:
onEndReached(axis, { distance, onReached }); // infinite loader trigger
stickyHeader(axis.firstIndex, groupStarts); // active group ordinalQuickstart
A 1,000,000-row list rendered as a single <lite-element>:
import { define } from "@zakkster/lite-element";
import { mountList } from "@zakkster/lite-virtual";
define("huge-list", (host, scope) => {
host.style.height = "400px";
mountList(host, scope, {
count: 1_000_000,
itemHeight: 32,
viewport: 400,
render: (rowEl, i) => {
rowEl.textContent = `row ${i}`;
rowEl.style.padding = "6px 12px";
rowEl.style.borderBottom = "1px solid #eee";
},
});
});<huge-list></huge-list>The DOM has ~21 <div> rows inside <huge-list>. Scrolling re-uses them.
Pure-math axes
For renderers that aren't a DOM list -- canvas / WebGL grids, terminal UIs, scroll-bound parallax -- use the math directly:
import { virtualAxis } from "@zakkster/lite-virtual";
import { effect } from "@zakkster/lite-signal";
const axis = virtualAxis({
count: 1_000_000,
itemSize: 18,
viewport: 600,
overscan: 3,
});
scrollEl.addEventListener("scroll", () => axis.setScroll(scrollEl.scrollTop));
effect(() => {
// Runs ONLY when start or end changes -- i.e. on boundary crossings.
drawCanvasWindow(ctx, axis.start(), axis.end(), axis.offsetStart());
});The effect body fires once per boundary crossing -- never on sub-row scrolls.
You can throttle / coalesce externally if you want fewer; the cutoff already
ensures you never do MORE work than necessary.
Recycling renderers
mountList -- index-based, stateless rows
Stateless content (text, static markup, derived display strings) goes through
mountList. A pool of perView + 2 * overscan row <div>s is created once;
each row's transform = translateY(index * itemHeight) and its content are
updated only when the index it represents changes. On a one-row scroll,
exactly one node updates.
mountList(host, scope, {
count: data.length,
itemHeight: 28,
viewport: host.clientHeight,
overscan: 3,
render: (rowEl, i) => {
rowEl.textContent = data[i];
},
});The recycling is INDEX-based -- meaning the same <div> is reused across
different logical indices. That's why rows must be stateless: any DOM state
on a row (input focus, video playback, accordion-expanded) would follow the
node, not the data row, and break the moment the user scrolls.
mountGrid -- 2-D recycling
mountGrid(host, scope, {
rowCount: 50_000,
colCount: 500,
rowHeight: 28,
colWidth: 120,
viewportWidth: host.clientWidth,
viewportHeight: host.clientHeight,
overscan: 2,
render: (cellEl, row, col) => {
cellEl.textContent = data[row][col];
},
});The pool is pre-sized to the maximum window size, so scrolling never
reshuffles the modulo mapping. Crossing a row boundary re-renders one new
row of cells; crossing a column boundary re-renders one new column.
Stateless cells (same constraint as mountList).
Stateful rows: when to use mountKeyedList
If a row owns DOM state -- an <input> you can type into, a <video> that's
playing, a <details> element the user expanded -- you cannot recycle nodes
by index slot. Each node has to stay with its row's identity.
mountKeyedList does that: a node is created when its key enters the window
and removed when the key leaves. Reorder moves nodes by transform; render
is not called again for an existing node.
import { signal } from "@zakkster/lite-signal";
const rows = signal([
{ id: "a", draft: "" },
{ id: "b", draft: "" },
/* ... */
]);
mountKeyedList(host, scope, {
items: () => rows(), // reactive accessor
itemHeight: 48,
viewport: host.clientHeight,
key: (item) => item.id,
render: (rowEl, item, index) => {
// Called ONCE per key. Bind reactively inside.
rowEl.innerHTML = `<input>`;
const inp = rowEl.firstChild;
inp.value = item.draft;
inp.addEventListener("input", (ev) => { item.draft = ev.target.value; });
},
});
// Reorder -- typed text and focus survive:
rows.update((arr) => [...arr].reverse());More allocation than mountList (one DOM node creation per key entering the
window, one removal per key leaving), but correct for stateful content.
Variable heights
If row heights are not uniform but you know them up front, use
mountVariableList. The library builds a prefix-sum offsets array once
(O(n)), then resolves each scroll position by binary search (O(log n)).
The no-thrash property carries over -- firstItem is still an integer, so
Object.is still gates downstream work.
const sizes = rows.map(measureHeight); // your own measurement
mountVariableList(host, scope, {
count: rows.length,
sizeAt: (i) => sizes[i],
viewport: host.clientHeight,
render: (rowEl, i) => {
rowEl.textContent = rows[i].text;
},
});If your sizes change later (responsive layout, font load, ResizeObserver on
rendered rows), call axis.remeasure() -- it walks sizeAt again and bumps
an internal version signal that re-triggers the downstream chain.
Measured / auto-height rows (1.1)
When sizes are unknown until rendered, use measuredAxis + mountMeasuredList.
Rows start at an estimate; a ResizeObserver reports each row's real height, and
the axis patches every downstream offset in O(log n) via a Fenwick tree
(not an O(n) rebuild). The scroll is re-anchored to the item under the viewport
top, so a correction above the fold does not visibly jump.
mountMeasuredList(host, scope, {
count: rows.length,
estimateSize: 40, // best guess before measurement
viewport: host.clientHeight,
render: (rowEl, i) => {
rowEl.textContent = rows[i].text; // any height; it gets measured
},
});The windowing, recycling, and no-thrash property are identical to the other
renderers; only the offset source is incremental. For the headless math alone
(measure rows yourself, drive your own renderer), use measuredAxis.
Observer safety
The classic gotcha with virtualization: a giant inner spacer leaks its size
to parent layout (flex grow, IntersectionObserver, parent ResizeObserver),
which feeds back into the library's viewport reading, which renders more
rows, which... infinite loop.
lite-virtual breaks the loop on two independent fronts:
host.style.overflow = "auto". The spacer (whosestyle.heightis the fullcount * itemSize) scrolls INSIDE the host. The host itself only takes the space its parent gave it. OuterIntersectionObserver/ResizeObserverseehost's ownclientHeight-- not the inflated spacer -- because the host clips.- The library's RO reads
contentRect.height, neverscrollHeight. So even in the (very unusual) case that some part of layout did inflate the host's reported size, the renderer would only see the actual visible viewport, not anything the spacer can influence.
This is pinned down by test/03-observers.test.js -- 10 tests including the worst case (simultaneous external IO + RO + scroll across 100K items), all asserting the pool stays bounded and no feedback loop is reachable.
The library does not register any IntersectionObserver of its own --
only ONE ResizeObserver per renderer, disconnected on scope.dispose().
API reference
virtualAxis(options) -> VirtualAxis
| option | type | default | notes |
|--------------|----------|---------|-----------------------------------------------|
| count | number | -- | number of items |
| itemSize | number | -- | pixel size along the scroll axis |
| viewport | number | -- | viewport size along the scroll axis |
| overscan | number | 3 | extra items kept mounted on each side |
Returns reactive scrollPos, start, end, offsetStart, totalSize,
firstIndex, count(), and offsetForIndex(index, align?) (clamped scroll
offset; align is "start" | "center" | "end" | "auto"), plus setters
setScroll, setViewport, setCount.
virtualGrid(options) -> VirtualGrid
| option | type | default |
|------------------|--------|---------|
| rowCount | number | -- |
| colCount | number | -- |
| rowHeight | number | -- |
| colWidth | number | -- |
| viewportHeight | number | -- |
| viewportWidth | number | -- |
| overscan | number | 2 |
Returns rowStart, rowEnd, rowOffset, totalHeight, colStart,
colEnd, colOffset, totalWidth, setScroll, setViewport, setCounts.
variableAxis(options) -> VariableAxis
| option | type | default |
|--------------|----------|---------|
| count | number | -- |
| sizeAt | (i) => number | -- |
| viewport | number | -- |
| overscan | number | 3 |
Returns the virtualAxis surface (firstIndex, count(), offsetForIndex
included) plus positionAt(i), minSize(), remeasure().
mountList(host, scope, options) -> VirtualAxis
| option | type | default | notes |
|--------------|----------|---------|------------------------------------------------------|
| count | number | -- | |
| itemHeight | number | -- | size along the scroll axis (or use itemSize) |
| itemSize | number | -- | alias for itemHeight; clearer when horizontal |
| horizontal | boolean | false | scroll on X (scrollLeft + translateX) |
| viewport | number | -- | initial -- auto-synced via ResizeObserver if available |
| overscan | number | 3 | |
| render | (rowEl, index) => void | -- | called when an index enters the window or recycles |
Returns the axis (use it for setCount on growth, etc), with
scrollToIndex(index, align?) attached.
mountKeyedList<T>(host, scope, options) -> VirtualAxis
| option | type | default |
|--------------|----------|---------|
| items | () => readonly T[] | -- |
| itemHeight | number | -- |
| viewport | number | -- |
| overscan | number | 3 |
| key | (item, index) => string \| number | -- |
| render | (rowEl, item, index) => void | -- |
mountGrid(host, scope, options) -> VirtualGrid
| option | type | default |
|------------------|----------|---------|
| rowCount | number | -- |
| colCount | number | -- |
| rowHeight | number | -- |
| colWidth | number | -- |
| viewportWidth | number | -- |
| viewportHeight | number | -- |
| overscan | number | 2 |
| render | (cellEl, row, col) => void | -- |
mountVariableList(host, scope, options) -> VariableAxis
| option | type | default |
|--------------|----------|---------|
| count | number | -- |
| sizeAt | (i) => number | -- |
| viewport | number | -- |
| overscan | number | 3 |
| render | (rowEl, index) => void | -- |
Returns the variable axis with scrollToIndex(index, align?) attached.
measuredAxis(options) -> MeasuredAxis
| option | type | default | notes |
|---------------|--------|---------|----------------------------------------|
| count | number | -- | |
| estimateSize| number | -- | per-item size before measurement |
| viewport | number | -- | |
| overscan | number | 3 | |
Auto-height headless axis: a Fenwick tree gives O(log n) measure(index, size)
plus O(log n) offset/find queries. Returns the variableAxis surface plus
sizeAt(i) and measure(index, size).
mountMeasuredList(host, scope, options) -> MeasuredAxis
| option | type | default | notes |
|---------------|--------|---------|----------------------------------------|
| count | number | -- | |
| estimateSize| number | -- | |
| viewport | number | -- | |
| overscan | number | 3 | |
| render | (rowEl, index) => void | -- | |
Measures each row via ResizeObserver, patches the axis, and re-anchors the
scroll to the item under the viewport top. Returns the measured axis with
scrollToIndex attached.
onEndReached(axis, options) -> dispose
| option | type | default | notes |
|-------------|-------------|---------|-----------------------------------------|
| distance | number | 0 | fire within this many items of the end |
| onReached | () => void| -- | called once per count; again after growth |
Headless infinite-loader trigger; works with any axis exposing count() and
end. Wire onReached to a loader (e.g. @zakkster/lite-resource).
stickyHeader(firstIndex, groupStarts) -> ReadSignal<number>
Given axis.firstIndex and a sorted ascending array of group-start indices,
returns a computed of the 0-based ordinal of the group at the top of the
viewport (-1 before the first). Bind a pinned header element to it.
Benchmarks
Measured on Node 22.22 with --expose-gc. Run yourself: npm run bench.
| Scenario | N | ops/sec | transient/op | retained/op | |-------------------------------------------------------------------|--------:|----------:|-------------:|------------:| | A) virtualAxis: sub-row scroll (1 px at a time, within a row) | 200K | ~3.6M | 0 B | 0 B | | B) virtualAxis: boundary crossing (lands on a new row each tick) | 200K | ~2.0M | 0 B | 0 B | | C) variableAxis: sub-item scroll (binary search converges) | 200K | ~2.6M | 0 B | 0 B | | D) variableAxis: boundary crossing (binary search + chain) | 200K | ~1.5M | 0 B | 0 B | | E) mountList: realistic scroll on 1M-item list | 50K | ~800K | ~25 B | ~1 B | | F) Naive baseline: re-render every visible row on every scroll| 50K | ~580K | ~60 B | 0 B |
Headline:
- Sub-row scroll is genuinely free. 3.6M ops/sec at 0 bytes/op transient is the cutoff: Object.is sees the same integer firstItem and short-circuits the entire chain. No effect re-runs, no DOM writes, no allocations.
- Boundary crossing is ~2M/sec. That's the cost of recomputing
start/end/offsetStartand propagating through one bind effect. For 60Hz scrolling, you have a ~30,000× budget -- boundary crossings are essentially never the bottleneck. - mountList handles ~800K full scroll events/sec on a 1M-item list in pure Node. The DOM-write savings vs. a naive "render every visible row" approach don't show clearly in a stub (no layout, no paint) -- they appear in the browser, where naive triggers ~21 style writes + reflow per scroll event vs lite-virtual's ~1 write per boundary.
- Pool size on 1M items: ~21 DOM nodes. Not 1M. Not 100. Exactly the visible window + 2× overscan.
Numbers vary ~10% run-to-run with GC timing. The bench file is
bench/bench.mjs; copy it, modify, re-run.
Edge cases pinned down
- iOS rubber-band scroll. Negative
scrollTop(overscroll up) pinsfirstItemto 0;startandoffsetStartstay at 0. No NaN renders. - Overscroll past the end.
scrollTop > totalSizepinsfirstItemtocount;startismax(0, count - overscan),endiscount. No crash. - Shrinking
countmid-scroll. CallingsetCount(smaller)while scrolled past the new end pinsstart/endto the new tail; the scrollbar shrinks; no off-by-one renders. - Growing
countmid-scroll.setCount(larger)updatestotalSizebut leaves the visible window where it was -- the user's scroll position is unchanged. - Viewport resize mid-scroll. The library's
ResizeObserverreadscontentRect.heightand callssetViewport. The pool grows to fit; it never shrinks (a300 -> 800 -> 300round-trip retains the larger pool so the next grow is allocation-free). scope.dispose()is complete. Every signal, computed, effect, listener, AND the renderer'sResizeObserverare disposed. Verified by test suite (53 tests, including an explicit RO-leak audit).- Pool grows ONCE. On first scroll off the top, the upper overscan is
no longer clipped and the pool grows from
perView + overscantoperView + 2 * overscan. After that, the pool never grows during scroll. - No internal IntersectionObserver. The library uses exactly one
ResizeObserverper renderer. External IO on the host can't trigger any internal behavior -- it observeshost's ownclientHeight, which the library does not depend on after mount. - Host becomes keyboard-focusable on mount. Each renderer sets
host.tabIndex = 0so Arrow / PageUp / PageDown / Spacebar reach the scroll container. If you already set atabIndex(positive or zero), the library leaves it alone. PasstabIndex = -1after mount to opt out. - The spacer is layout-invariant. Each renderer's internal spacer is
position: absolute, 1 px wide,visibility: hidden,aria-hidden. It drivesscrollHeightvia an explicit pixel height, independent of the host's formatting context (flex, grid, block -- all behave the same). No margin collapse, no flex-row sideways layout, no sub-pixel rounding.
What this is not
- Auto-height is measure-then-correct, not pre-layout.
mountMeasuredListestimates first and corrects after theResizeObserverfires, re-anchoring the scroll. A row's true height is known one frame after it renders, not before -- if you know sizes up front,mountVariableListis cheaper. - Not a renderer per row. Recycling renderers reuse a single
<div>pool. For per-row React/Vue/Solid components, build your own on top ofvirtualAxis-- the math is everything you need. - Not for non-scrolling layouts. Windowing assumes a scroll container. CSS Grid auto-fill, marquee, parallax-without-scroll -- different problem.
- Not a framework wrapper. The recycling renderers expect a lite-element
scope (
effect,on,onCleanup). For other reactive systems, write a 20-line adapter or use the math directly.
Browser / runtime support
| target | works | notes |
|------------------|:-----:|------------------------------------------|
| Node >= 18 | yes | math only -- renderers need a DOM |
| Chrome / Edge | yes | ResizeObserver shipped 64 |
| Firefox | yes | RO shipped 69 |
| Safari >= 13.1 | yes | RO shipped 13.1 |
| Deno / Bun | yes (math) | ditto Node |
ESM only. No CJS build. If you need CJS, bundle through esbuild/rollup.
Peer dependency
"peerDependencies": { "@zakkster/lite-signal": "^1.1.3" }The recycling renderers also assume a lite-element-shaped mount scope
(effect, on, onCleanup). @zakkster/lite-element ^1.0.0 provides it;
a 20-line adapter wraps any other reactive system.
FAQ
Q: Why is mountList index-based instead of always keyed?
Index-based recycling is O(1) per boundary crossing with zero allocation --
the same <div> is reused across logical indices. That's the cheapest
possible scroll path. The trade-off is statelessness: DOM state would
follow the node, not the row. For static lists (text, derived display),
that's a non-issue; use mountList. For inputs/media/expansion state, use
mountKeyedList and pay the create/remove cost (still 1 per boundary).
Q: Will my list freeze when the spacer gets huge?
No. Browsers handle style.height = "30000000px" fine -- the scrollbar maps
to the value, but no layout cost is proportional to the spacer's pixel
size. We've tested up to count * itemSize = 30M px (1M items × 30px)
without issue.
Q: Can I scroll to a specific index?
Yes -- the renderers attach axis.scrollToIndex(index, align?) where align is
"start" | "center" | "end" | "auto" ("auto" only moves when the row is
off-screen), and the grid attaches grid.scrollToCell(row, col, align?). For
headless math, every axis exposes offsetForIndex(index, align?) returning the
clamped scroll offset. Wrap it with behavior: "smooth" yourself if you want
animated scrolling.
Q: What's the difference between overscan and the +1 in perView?
The +1 is for the partial row at the bottom edge of the viewport that's
always visible. overscan adds buffer rows on EACH side so scrolling
doesn't reveal blank space during the brief moment between scroll event
and effect re-run. Three rows on each side is plenty for most workloads.
Q: My rows have <input> and editing causes them to lose focus when I scroll.
You're using mountList (index-based recycling) with stateful rows. Switch
to mountKeyedList -- the node stays with the data row across scrolls.
Q: How do I add sticky headers / sub-headers / dividers?
For a pinned section header, pass your sorted group-start indices to
stickyHeader(axis.firstIndex, groupStarts) -- it returns a reactive ordinal of
the group currently at the top of the viewport; bind a pinned header element's
content to it. The axis itself only knows uniform rows, so inline dividers can
also be modelled as items whose render switches on data[i].kind.
Q: How do I load more as the user scrolls (infinite list)?
onEndReached(axis, { distance, onReached }) fires onReached once when the
window comes within distance items of the end, and again only after the count
grows -- wire onReached to your loader (e.g. @zakkster/lite-resource's
loadMore), then axis.setCount(newLength) when it resolves.
Q: Why isn't there a "smooth scroll to index" helper?
Because the browser already has host.scrollTo({ top, behavior: "smooth" }).
Call that with axis.positionAt(index) and you're done.
Q: Is variable axis async-safe? What if sizeAt throws?
sizeAt is called synchronously inside build() and (for the renderer)
on render. If it throws, build throws; no inconsistent state is committed.
Don't make sizeAt do anything fancier than an array lookup.
Q: Can I use this with React / Vue / Svelte?
The pure-math axes (virtualAxis, virtualGrid, variableAxis) -- yes,
trivially. Subscribe to start / end from your framework's reactive
system. The recycling renderers -- only if you can supply a lite-element-
shaped scope; for that, a 20-line adapter is straightforward.
License
MIT (c) Zahary Shinikchiev
The @zakkster stack
- @zakkster/lite-signal -- the reactive primitives this all builds on
- @zakkster/lite-element -- Custom Elements with state that survives reparents
- @zakkster/lite-time -- drift-corrected wall-clock cadence
- @zakkster/lite-form -- headless reactive forms
- @zakkster/lite-virtual -- this package
