Virtual Scroll
<cui-virtual-scroll>
Live Example
Loaded rows: {{ items.length }} (capped at 150 via max-item-count)
Documentation
cui-virtual-scroll Component Documentation
Overview
cui-virtual-scroll is a headless (renderless) Vue 3 utility component that provides vertical virtual scrolling over an in-memory dataset array. It does not render the scrollable element itself — instead it monitors an existing scrollable element (identified by a CSS selector) and, when the scroll position approaches the top or bottom edge, notifies a child "loader" component (such as cui-HttpRequest) placed in its default slot to fetch additional records.
The component is designed to be paired with a loader in its default slot, using the slot props it exposes (dataset, blockSize, edgeValue, loadHeadTrigger, loadTailTrigger) to drive the loader's request parameters and trigger mechanism.
Key responsibilities:
- Detect when the scroll position is within a configurable threshold of the top or bottom of the scrollable element and signal that more data should be loaded in that direction.
- Optionally cap the size of the dataset by trimming elements from the end opposite the direction that just grew, keeping memory/DOM usage bounded for long-running infinite scroll scenarios. Two mutually-exclusive trimming strategies are supported: a simple, efficient count-based cap (
maxItemCount), best suited to consistent, fixed-height rows; and a height-based cap (virtualMaxPx, optionally aided byavgItemSizePx), best suited to variable-height rows (e.g. cards or grids) where a row count alone can't predict rendered height. When both are set,maxItemCounttakes precedence. - Preserve the visual scroll position when content is prepended to the head (or trimmed from the head), so the viewport doesn't visually jump.
- Avoid load/trim oscillation by never trimming while the scroll position sits in a trigger zone, and by suppressing the threshold check for an edge immediately after it was trimmed.
- Support external reset, disable, and "no more data" signaling.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
scrollElementSelector |
String | required | CSS selector identifying the scrollable element to monitor. The element is queried via document.querySelector and is not rendered by this component. |
keyField |
String | required | The property name on each dataset object that uniquely identifies that object instance. Used to compute edgeValue and to detect how many rows were added on each end after a load. |
blockSize |
Number | 100 |
Number of additional rows to load when a threshold is reached. Passed through to the default slot as blockSize for the child loader to use as a page/limit size. |
scrollThreshold |
Number | String | 200 |
Distance from the top/bottom edge of the scrollable element that triggers a load. Accepts a plain number of pixels, or a string percentage (e.g. "25%") which is resolved against the element's scrollHeight (the total scrollable content height, not just the visible viewport). |
dataset |
Array | required | The in-memory array currently loaded and displayed. Mutated in place (prepended to the head/appended to the tail by the loader, trimmed by this component) rather than replaced. |
maxItemCount |
Number | null |
When set (> 0), defines the maximum number of rows allowed in dataset. Whenever dataset.length changes and exceeds this value, the excess row count is trimmed from the end opposite the one that just grew — no height measurement or estimation is needed since the exact number of rows to remove is already known. Trimming never removes rows while the scroll position is within a trigger zone (top/bottom threshold), to avoid load/trim oscillation. When maxItemCount is set, it is used exclusively for trimming and virtualMaxPx/avgItemSizePx are ignored. Best suited to datasets with consistent, fixed-height rows. |
virtualMaxPx |
Number | null |
When set (> 0) and maxItemCount is not set, defines the maximum allowed scrollHeight (in pixels) of the scrollable element. Whenever dataset.length changes, if the element's scrollHeight exceeds this value, elements are trimmed from the end opposite the one that grew until the height is back under the limit. Trimming never removes rows while the scroll position is within a trigger zone (top/bottom threshold), to avoid load/trim oscillation. When null or <= 0 (or when maxItemCount is set), no height-based trimming occurs. Best suited to datasets with variable-height rows (e.g. cards or grids) where a fixed item count can't reliably predict rendered height. |
avgItemSizePx |
Number | null |
Only used when trimming via virtualMaxPx (i.e. maxItemCount is not set). When set (> 0), used as an estimate of the average pixel height of a dataset row. This lets virtualMaxPx enforcement remove an estimated batch of elements in one step (minimizing remove/remeasure iterations) before falling back to a one-at-a-time loop to fine-tune the result. The estimated batch size is capped so it can never remove more rows than are safely outside the opposite trigger zone, and the resulting scrollTop compensation is hard-clamped to that same safe boundary — protecting against the estimate under-representing real row height. |
reset |
Boolean | Number | String | false |
When set to any truthy value, the component resets its internal load/trim tracking state and scrolls the target element back to the top, then emits update:reset with false. Falsy values have no effect. Intended to be used with v-model:reset. |
disabled |
Boolean | false |
When true, all threshold-based load triggers are suppressed regardless of scroll position, and virtualMaxPx is not enforced (no trimming occurs). When toggled back to false, thresholds and trimming are immediately re-evaluated against the current scroll position. |
disableTrim |
Boolean | false |
When true, virtualMaxPx/avgItemSizePx trimming is suppressed entirely (load triggers are unaffected). When toggled back to false, a trim evaluation is forced immediately based on the direction of the last known dataset growth — regardless of the current scroll position — since the dataset may have grown well past virtualMaxPx while trimming was disabled. |
Events
| Event | Payload | Description |
|---|---|---|
update:reset |
false |
Emitted immediately after a truthy reset prop value has been processed, so a v-model:reset binding automatically flips back to false. |
Default Slot Props
The default slot exposes the following props, intended to be bound to a loader component such as cui-HttpRequest:
| Slot Prop | Type | Description |
|---|---|---|
dataset |
Array | The current in-memory dataset. Loaders should append to the tail / prepend to the head of this array directly (e.g. dataset.push(...) / dataset.unshift(...)) rather than replacing it, so the component can detect growth direction correctly. |
blockSize |
Number | The configured blockSize prop value — how many additional rows to request. |
headValue |
* | The key value (from keyField) of the current first element of the dataset, kept continuously up to date — including while virtualMaxPx trimming removes rows from the head. Intended for the head loader's cursor/paging parameter (e.g. ?before=headValue). |
tailValue |
* | The key value (from keyField) of the current last element of the dataset, kept continuously up to date — including while virtualMaxPx trimming removes rows from the tail. Intended for the tail loader's cursor/paging parameter (e.g. ?after=tailValue). |
edgeValue |
* | combined value: set to whichever edge (headValue or tailValue) most recently changed. |
loadHeadTrigger |
Number | Incremented each time the top-of-list threshold is crossed and a head load should occur. A loader can watch this value (or bind it to trigger-on) to know when to fetch older/earlier rows. |
loadTailTrigger |
Number | Incremented each time the bottom-of-list threshold is crossed and a tail load should occur. A loader can watch this value (or bind it to trigger-on) to know when to fetch newer/later rows. |
edge |
String | null | A single-character indicator of which edge most recently triggered a load: 'H' for head, 'T' for tail. null until the first load is triggered (and after reset). Useful when a single loader handles both directions and needs to know which cursor (headValue vs tailValue) to use for the current request. |
DOM Events Consumed
The component listens for the following custom DOM events dispatched (with bubbles: true) on the scroll element (e.g. by a loader placed in the default slot), typically after a load request completes and returns zero additional records:
| Event | Effect |
|---|---|
NoItemsHead |
Suppresses further head (top) load triggers until the scroll position moves back out of the top threshold zone. Also clears the pending head load state (and its anchor key), since the in-flight request has resolved with zero rows. |
NoItemsTail |
Suppresses further tail (bottom) load triggers until the scroll position moves back out of the bottom threshold zone. Also clears the pending tail load state (and its anchor key), since the in-flight request has resolved with zero rows. |
Example of a loader signaling exhaustion after receiving an empty page:
document.querySelector('#myScrollArea')
.dispatchEvent(new CustomEvent('NoItemsTail', { bubbles: true }));
Behavior Notes
- Initial Load if
datasetis null or empty when the component mounts it will trigger an inital tail load withedgeValue=nullandtailValue=null - Trim direction: When
virtualMaxPxtrimming is needed, rows are removed from the end opposite the end that grew. If both ends grew in the same tick, trimming alternates between the two ends so neither freshly-loaded block is discarded outright. - Anchor tracking through trimming: If a head (or tail) load is still pending while trimming removes rows from that same end, the pending load's anchor key (and
edgeValue) is refreshed to the new edge row after each removal. This ensures the anchor always reflects the current head/tail of the dataset, so the in-flight load can still be correctly located once it resolves, and any subsequent load request will use an up-to-date cursor value rather than a stale one. - Estimated batch safety: When
avgItemSizePxis used to remove a batch of rows in one step, the batch size is capped so it can never remove more rows than safely fit within the distance to the opposite trigger zone. The resultingscrollTopcompensation is also hard-clamped to that same safe boundary, so if real row heights are larger thanavgItemSizePxestimated (a common case), the correction can't overshoot past the boundary and snap the view to the very start (or end) of the dataset. - Scroll position preservation: When rows are prepended to the head (via a head load) or trimmed from the head (via
virtualMaxPxenforcement), the component compensatesscrollTopby the resultingscrollHeightdelta so the currently-visible content does not visually jump. - Scroll listener throttling: The
scrollevent is throttled viarequestAnimationFrame, so at most one threshold check occurs per animation frame regardless of how manyscrollevents fire. - Deferred element resolution: If
scrollElementSelectordoesn't resolve to an element at mount time (e.g. it's rendered by the parent in a later tick), the component retries with incremental backoff, and also retries whenever the dataset changes while the element remains unresolved. - Reset: Setting
resetto a truthy value clears all pending/anchor/"no more data" state, resets trigger counters to0, scrolls to the top, and then emitsupdate:resetset tofalse. - Disabled: While
disabledistrue, no load triggers fire andvirtualMaxPxis not enforced — the dataset is left untouched. Re-enabling immediately re-evaluates both. - Disable trim: While
disableTrimistrue, load triggers continue to fire normally, butvirtualMaxPx/avgItemSizePxtrimming is skipped, allowing the dataset to grow unbounded. WhendisableTrimtransitions back tofalse, a trim pass is forced using the direction of the last known growth (head or tail), bypassing the normal "don't trim while in a trigger zone" safeguard, since a large backlog may need to be trimmed even if the scroll position happens to sit inside a trigger zone at that moment. - Initial head-zone suppression: A head load is never triggered until the scroll position has moved out of the head trigger zone at least once. Without this, a scrollable element that starts at
scrollTop = 0(the normal starting position) would already sit inside the head threshold zone at mount, causing a spurious head load to fire immediately even though the user hasn't scrolled up at all.resetre-arms this suppression as well, since it also scrolls back to the top.
Basic Usage
<template>
<div id="scroll-area" style="height: 400px; overflow-y: auto;">
<cui-virtual-scroll
scroll-element-selector="#scroll-area"
key-field="id"
:dataset="items"
:block-size="25"
scroll-threshold="20%"
:virtual-max-px="4000"
:avg-item-size-px="40"
>
<template v-slot="{ dataset, blockSize, headValue, tailValue, loadHeadTrigger, loadTailTrigger }">
<cui-http-request
url="/api/items"
:qparams="{ after: tailValue, limit: blockSize }"
:trigger-on="loadTailTrigger"
@received="data => dataset.push(...data)"
/>
<cui-http-request
url="/api/items"
:qparams="{ before: headValue, limit: blockSize }"
:trigger-on="loadHeadTrigger"
@received="data => dataset.unshift(...data)"
/>
</template>
</cui-virtual-scroll>
<div v-for="item in items" :key="item.id" class="row">
{{ item.name }}
</div>
</div>
</template>
<script>
export default {
data() {
return {
items: [ /* initial page of rows */ ],
};
},
};
</script>
Usage with Reset and Disabled
<template>
<button @click="resetScroll = true">Reset List</button>
<label>
<input type="checkbox" v-model="scrollingDisabled" /> Pause infinite scroll
</label>
<div id="scroll-area" style="height: 400px; overflow-y: auto;">
<cui-virtual-scroll
scroll-element-selector="#scroll-area"
key-field="id"
:dataset="items"
:block-size="25"
v-model:reset="resetScroll"
:disabled="scrollingDisabled"
>
<template v-slot="{ dataset, blockSize, tailValue, loadTailTrigger }">
<cui-http-request
url="/api/items"
:qparams="{ after: tailValue, limit: blockSize }"
:trigger-on="loadTailTrigger"
@received="data => {
if (data.length === 0) {
document.querySelector('#scroll-area')
.dispatchEvent(new CustomEvent('NoItemsTail', { bubbles: true }));
} else {
dataset.push(...data);
}
}"
/>
</template>
</cui-virtual-scroll>
<div v-for="item in items" :key="item.id" class="row">
{{ item.name }}
</div>
</div>
</template>
<script>
export default {
data() {
return {
items: [],
resetScroll: false,
scrollingDisabled: false,
};
},
};
</script>
Usage with Bidirectional Loading and Percentage Threshold
<cui-virtual-scroll
scroll-element-selector="#chat-window"
key-field="messageId"
:dataset="messages"
:block-size="50"
scroll-threshold="15%"
>
<template #default="{ dataset, blockSize, headValue, tailValue, loadHeadTrigger, loadTailTrigger }">
<!-- Load older messages when scrolling up -->
<cui-http-request
url="/api/messages/before"
:qparams="{ before: headValue, limit: blockSize }"
:trigger-on="loadHeadTrigger"
@received="data => dataset.unshift(...data)"
/>
<!-- Load newer messages when scrolling down -->
<cui-http-request
url="/api/messages/after"
:qparams="{ after: tailValue, limit: blockSize }"
:trigger-on="loadTailTrigger"
@received="data => dataset.push(...data)"
/>
</template>
</cui-virtual-scroll>
Usage with a Single Loader Using edge
Rather than wiring up two separate cui-http-request instances (one per edge), a single loader can be bound to both triggers at once and use the edge slot prop to tell the backend which direction to load and which cursor to use. This works well when a single API endpoint accepts an edge/cursor pair and returns the next block of rows for whichever edge was requested.
<template>
<div id="scroll-area" style="height: 400px; overflow-y: auto;">
<cui-virtual-scroll
scroll-element-selector="#scroll-area"
key-field="id"
:dataset="items"
:block-size="50"
>
<template #default="{ dataset, blockSize, edgeValue, loadHeadTrigger, loadTailTrigger, edge }">
<!-- trigger-on fires whenever either counter increments; edge indicates -->
<!-- which edge the current trigger is for, and edgeValue holds that edge's cursor. -->
<cui-http-request
url="/api/items"
:qparams="{ edge: edge, cursor: edgeValue, limit: blockSize }"
:trigger-on="loadHeadTrigger + loadTailTrigger"
@received="data => {
if (data.length === 0) {
document.querySelector('#scroll-area').dispatchEvent(
new CustomEvent(edge === 'H' ? 'NoItemsHead' : 'NoItemsTail', { bubbles: true })
);
} else if (edge === 'H') {
dataset.unshift(...data);
} else {
dataset.push(...data);
}
}"
/>
</template>
</cui-virtual-scroll>
<div v-for="item in items" :key="item.id" class="row">
{{ item.name }}
</div>
</div>
</template>
<script>
export default {
data() {
return {
items: [ /* initial page of rows */ ],
};
},
};
</script>
On the backend, the endpoint would inspect the edge query parameter ("H" or "T") to decide whether cursor represents the oldest or newest loaded row, and return the corresponding older/newer block of rows:
public IActionResult OnGetItems(string edge, int? cursor, int limit = 50)
{
var query = edge == "H"
? _items.Where(i => i.Id < cursor).OrderByDescending(i => i.Id).Take(limit).OrderBy(i => i.Id)
: _items.Where(i => cursor == null || i.Id > cursor).OrderBy(i => i.Id).Take(limit);
return new JsonResult(query.ToList());
}
Note: Since
loadHeadTriggerandloadTailTriggerare independent counters, summing them (loadHeadTrigger + loadTailTrigger) fortrigger-onensures the loader fires whenever either one increments.edgealways reflects whichever edge most recently triggered, so it stays in sync with the trigger that caused the current request, andedgeValuealways holds that same edge's cursor value.
Notes
- The component has no visible template output of its own beyond its default slot; it is purely functional/logic-driven, similar in spirit to
cui-filter-sort-pageandcui-fsp-debounce. - Because
datasetis mutated in place (viapush/unshift/pop/shift) rather than replaced, the parent's array reference stays valid and reactive throughout loads and trims — no additional synchronization orupdate:datasetevent is required for normal operation. scrollElementSelectormust resolve to an element withoverflow-y: auto(orscroll) and a bounded height/max-heightfor scroll-based triggering to function.virtualMaxPxandavgItemSizePxare independent ofblockSize—blockSizecontrols how much is requested per load, whilevirtualMaxPx/avgItemSizePxcontrol how much is retained in memory/DOM afterward.