Vue 3 bindings for Cerious Scroll™: high-performance virtual scrolling with O(1) memory, consistent 60 FPS+, and native variable-height support with no height estimation.
Rows are rendered into the engine's own measured containers via Vue's synchronous render(), so every row's real height is measured (never estimated): exactly the guarantee that makes CeriousScroll precise. Rows are rendered with your app's appContext, so globally registered components, directives, and installed plugins work normally inside each row.
npm install @ceriousdevtech/vue-cerious-scroll @ceriousdevtech/cerious-scroll
vue (>= 3.3) is a peer dependency.
Live demo →: 100,000 rows, fixed/variable-height toggle, imperative jump-to-row, and live viewport stats.
To run locally:
npm install
npm run demo # dev server with HMR
npm run demo:build # production build to demo/dist
The demo imports the wrapper by its package name, aliased to the library source,
so edits to src/ are reflected live.
Give the container a height; provide items and an #item scoped slot.
<script setup lang="ts">
import { CeriousScroll } from '@ceriousdevtech/vue-cerious-scroll';
const items = Array.from({ length: 1_000_000 }, (_, i) => ({ id: i, name: `Item ${i}` }));
</script>
<template>
<CeriousScroll :items="items" :style="{ height: '480px' }">
<template #item="{ item, index }">
<div class="row">{{ index }}, {{ item.name }}</div>
</template>
</CeriousScroll>
</template>
Variable heights need no configuration, just render rows of whatever height; the engine measures each one.
<CeriousScroll
:total-elements="100_000_000"
:get-item="(index) => loadRow(index)"
:style="{ height: '600px' }"
>
<template #item="{ item, index }">
<Row :data="item" :index="index" />
</template>
</CeriousScroll>
useCeriousScroll gives you full control. Attach containerRef to your scroll
element; the composable renders the rows imperatively into the engine's measured
containers.
<script setup lang="ts">
import { h } from 'vue';
import { useCeriousScroll } from '@ceriousdevtech/vue-cerious-scroll';
const { containerRef } = useCeriousScroll({
items,
renderItem: (item, index) => h('div', { class: 'row' }, `${index}, ${item.name}`),
});
</script>
<template>
<div ref="containerRef" style="height: 480px; position: relative; overflow: hidden" />
</template>
renderItemreturns a VueVNodeChild(useh(...), or render JSX/TSX).
| Prop | Type | Description |
|---|---|---|
items | readonly TItem[] | Optional data array. totalElements defaults to items.length. |
totalElements | number | Total item count. Required if items is omitted. |
getItem | (index) => TItem | Lazy item getter for large/sparse datasets. |
renderItem | (item, index) => VNodeChild | Render prop alternative to the #item scoped slot. |
options | CeriousScrollOptions | Engine options. Masonry's DOM callback is supplied by the wrapper. Read once at creation. |
autoRender | boolean | Re-render on scroll/resize/data changes. Default true. |
The row is provided by the #item scoped slot ({ item, index }) or the
render-item prop. In table mode, a #header slot renders the <thead> row
(see Table layout). Apply class / style directly to the
component: they fall through onto the scroll container (set a height!).
| Event | Payload | Description |
|---|---|---|
viewport-change | CeriousViewportChangeDetail | Normalized viewport-change (wheel/touch/keyboard/scrollbar). |
measured-viewport | MeasuredViewportRange | Measured range after each render pass. |
ready | CeriousScrollEngine | The underlying engine instance, once ready. |
ref)const scroll = ref<InstanceType<typeof CeriousScroll> | null>(null);
// scroll.value?.jumpToElement(500);
// scroll.value?.jumpToItem(500); // Masonry cards
// scroll.value?.scrollToPercentage(50);
// scroll.value?.reset();
// scroll.value?.render();
// scroll.value?.recalculate(); // drop cached heights + re-measure (see Notes)
// scroll.value?.scroller; // the raw engine
Set layout: 'masonry'; the wrapper renders the #item slot into each core
Masonry card, so no imperative DOM callback is exposed:
<CeriousScroll
ref="scroll"
class="gallery"
:total-elements="photos.length"
:get-item="(index) => photos[index]"
:options="{
layout: 'masonry',
masonry: {
getItemHeight: (_index, width) => width * 0.75 + 48,
targetColumnWidth: 280,
gap: 16
}
}"
>
<template #item="{ item: photo, index }">
<PhotoCard :photo="photo" :index="index" />
</template>
</CeriousScroll>
Omit getItemHeight for dynamic DOM measurement. Vue creates a short-lived
offscreen render tree for measurement and disposes it after the synchronous
height read; visible cards remain reactive Vue trees with provide/inject and
event handling.
Use scroll.value?.jumpToItem(index, screenOffset?) for card navigation. The
demo gallery includes canonical and dynamic Masonry pages.
Pass :options="{ layout: 'table' }" to render real <table> / <tr> / <td> rows with a frozen header and native column alignment. The #item slot returns the row's <td> cells; a #header slot provides the (declarative, reactive) <thead> row:
<CeriousScroll
class="my-scroll"
:total-elements="100000"
:get-item="(i) => i"
:options="{ layout: 'table', table: { tableClassName: 'my-table', autoSizeColumns: true } }"
>
<template #header>
<tr><th v-for="c in columns" :key="c.key">{{ c.label }}</th></tr>
</template>
<template #item="{ item: index }">
<td>{{ row(index).id }}</td>
<td>{{ row(index).name }}</td>
<td>{{ row(index).email }}</td>
</template>
</CeriousScroll>
#header slot renders into the engine's <thead> (same <table> as the rows → native column alignment, frozen header) and stays reactive.#item slot must return <td>s. They render into the row's <tr> via a display: contents wrapper that isolates Vue's renderer from the engine's row recycling.table.autoSizeColumns measures column widths once and pins them (auto-sized + stable); or use table.columnWidths. Variable row heights work as usual.border-collapse: separate and an opaque <thead> background (see the core README's Table Layout notes).These are engine options, set through options and forwarded to the core
unchanged. They combine with any layout unless noted.
| Option | What it does |
|---|---|
sticky | Pins one dataset row to the top while you are inside its section. The pinned element is drawn outside the recycler, so it survives its own row scrolling out of the mounted window. |
snap | Settles the camera on a row boundary after scrolling stops. |
infinite | Calls onLoadMore as a threshold near an edge is crossed, once per approach. In Vue you grow the dataset by assigning a longer array; the camera does not move. |
aria | Writes aria-setsize and aria-posinset from the real dataset, so a screen reader announces "item 40,112 of 500,000" rather than the size of the mounted window. Opt-in. |
direction | 'ltr', 'rtl' or 'auto' to read the host's own computed direction. |
ssr | hydrate: true adopts pre-rendered rows on the first render instead of clearing them, matched by data-element-index. |
<CeriousScroll
:items="rows"
:options="{
sticky: { resolve: (first) => sectionOf[first] ?? null, className: 'is-pinned' },
snap: { enabled: true, align: 'nearest', tolerance: 2 },
infinite: { threshold: 20, edges: 'end', onLoadMore },
aria: { enabled: true, label: 'Search results' },
direction: 'auto',
}"
>
<template #item="{ item: row }"><Row :row="row" /></template>
</CeriousScroll>
options is read once, at creation. To flip one of these at runtime, re-key the
component.
sticky in VueThe engine renders the pinned header through your own item slot, so a section
header and that same row scrolling past are one piece of markup. The binding
gives the pinned element its own reactive mount, kept apart from the recycled
rows, which is what lets it survive its row leaving the mounted window.
Because one index is drawn in two places at once, the item slot must be
idempotent: a pure function of the item, with no side effects. It already had
to be for Masonry's measurement probe.
infinite in VueReturn the promise. The callback fires once per approach and re-arms when the window leaves the threshold; while a returned promise is pending no further call is made, which is what stops a slow endpoint being asked again on the next frame.
function onLoadMore(ctx: InfiniteLoadContext) {
return fetch(`/api/rows?after=${cursor}`)
.then((r) => r.json())
.then((page) => {
rows.value = rows.value.concat(page.items);
});
}
ssr in VueServer-render with the same row component the client uses, wrap each row in an
element carrying data-element-index, and put them inside an element marked
data-cerious-scroll-content.
Use the composable rather than the component for this: <CeriousScroll>
renders its own children into the container, so markup placed there by other
means is markup Vue does not track, and its next patch walks into nodes that
have moved. The composable hands back a bare container instead.
<script setup lang="ts">
const { containerRef } = useCeriousScroll({
totalElements: total,
getItem: (index) => index,
renderItem: (index) => h(Row, { index }),
options: { ssr: { hydrate: true } },
});
</script>
<template>
<div ref="containerRef" class="feed" />
</template>
The module touches no DOM at import time, so importing it in a server bundle is safe.
render()
so the engine measures real offsetHeight. Later size changes are picked up by
the engine's built-in ResizeObserver.options are read at creation. Changing options after mount has no
effect; remount (e.g. with a :key) to apply new engine options.items array on every edit don't trigger a full viewport re-measure.recalculate() (on the template ref, or from the composable
result) right after the change to drop the height cache and re-measure. Don't
call it on routine edits: a single cell edit keeps its row's size, and the
engine's built-in ResizeObserver picks up any incidental resize on its own.Licensed by Cerious DevTech LLC under the MIT License (see LICENSE-MIT).
32 commits
TypeScript
100.0%
Vue 3 bindings for Cerious Scroll™: high-performance virtual scrolling with O(1) memory, consistent 60 FPS+, and native variable-height support with no height estimation.
Rows are rendered into the engine's own measured containers via Vue's synchronous render(), so every row's real height is measured (never estimated): exactly the guarantee that makes CeriousScroll precise. Rows are rendered with your app's appContext, so globally registered components, directives, and installed plugins work normally inside each row.
npm install @ceriousdevtech/vue-cerious-scroll @ceriousdevtech/cerious-scroll
vue (>= 3.3) is a peer dependency.
Live demo →: 100,000 rows, fixed/variable-height toggle, imperative jump-to-row, and live viewport stats.
To run locally:
npm install
npm run demo # dev server with HMR
npm run demo:build # production build to demo/dist
The demo imports the wrapper by its package name, aliased to the library source,
so edits to src/ are reflected live.
Give the container a height; provide items and an #item scoped slot.
<script setup lang="ts">
import { CeriousScroll } from '@ceriousdevtech/vue-cerious-scroll';
const items = Array.from({ length: 1_000_000 }, (_, i) => ({ id: i, name: `Item ${i}` }));
</script>
<template>
<CeriousScroll :items="items" :style="{ height: '480px' }">
<template #item="{ item, index }">
<div class="row">{{ index }}, {{ item.name }}</div>
</template>
</CeriousScroll>
</template>
Variable heights need no configuration, just render rows of whatever height; the engine measures each one.
<CeriousScroll
:total-elements="100_000_000"
:get-item="(index) => loadRow(index)"
:style="{ height: '600px' }"
>
<template #item="{ item, index }">
<Row :data="item" :index="index" />
</template>
</CeriousScroll>
useCeriousScroll gives you full control. Attach containerRef to your scroll
element; the composable renders the rows imperatively into the engine's measured
containers.
<script setup lang="ts">
import { h } from 'vue';
import { useCeriousScroll } from '@ceriousdevtech/vue-cerious-scroll';
const { containerRef } = useCeriousScroll({
items,
renderItem: (item, index) => h('div', { class: 'row' }, `${index}, ${item.name}`),
});
</script>
<template>
<div ref="containerRef" style="height: 480px; position: relative; overflow: hidden" />
</template>
renderItemreturns a VueVNodeChild(useh(...), or render JSX/TSX).
| Prop | Type | Description |
|---|---|---|
items | readonly TItem[] | Optional data array. totalElements defaults to items.length. |
totalElements | number | Total item count. Required if items is omitted. |
getItem | (index) => TItem | Lazy item getter for large/sparse datasets. |
renderItem | (item, index) => VNodeChild | Render prop alternative to the #item scoped slot. |
options | CeriousScrollOptions | Engine options. Masonry's DOM callback is supplied by the wrapper. Read once at creation. |
autoRender | boolean | Re-render on scroll/resize/data changes. Default true. |
The row is provided by the #item scoped slot ({ item, index }) or the
render-item prop. In table mode, a #header slot renders the <thead> row
(see Table layout). Apply class / style directly to the
component: they fall through onto the scroll container (set a height!).
| Event | Payload | Description |
|---|---|---|
viewport-change | CeriousViewportChangeDetail | Normalized viewport-change (wheel/touch/keyboard/scrollbar). |
measured-viewport | MeasuredViewportRange | Measured range after each render pass. |
ready | CeriousScrollEngine | The underlying engine instance, once ready. |
ref)const scroll = ref<InstanceType<typeof CeriousScroll> | null>(null);
// scroll.value?.jumpToElement(500);
// scroll.value?.jumpToItem(500); // Masonry cards
// scroll.value?.scrollToPercentage(50);
// scroll.value?.reset();
// scroll.value?.render();
// scroll.value?.recalculate(); // drop cached heights + re-measure (see Notes)
// scroll.value?.scroller; // the raw engine
Set layout: 'masonry'; the wrapper renders the #item slot into each core
Masonry card, so no imperative DOM callback is exposed:
<CeriousScroll
ref="scroll"
class="gallery"
:total-elements="photos.length"
:get-item="(index) => photos[index]"
:options="{
layout: 'masonry',
masonry: {
getItemHeight: (_index, width) => width * 0.75 + 48,
targetColumnWidth: 280,
gap: 16
}
}"
>
<template #item="{ item: photo, index }">
<PhotoCard :photo="photo" :index="index" />
</template>
</CeriousScroll>
Omit getItemHeight for dynamic DOM measurement. Vue creates a short-lived
offscreen render tree for measurement and disposes it after the synchronous
height read; visible cards remain reactive Vue trees with provide/inject and
event handling.
Use scroll.value?.jumpToItem(index, screenOffset?) for card navigation. The
demo gallery includes canonical and dynamic Masonry pages.
Pass :options="{ layout: 'table' }" to render real <table> / <tr> / <td> rows with a frozen header and native column alignment. The #item slot returns the row's <td> cells; a #header slot provides the (declarative, reactive) <thead> row:
<CeriousScroll
class="my-scroll"
:total-elements="100000"
:get-item="(i) => i"
:options="{ layout: 'table', table: { tableClassName: 'my-table', autoSizeColumns: true } }"
>
<template #header>
<tr><th v-for="c in columns" :key="c.key">{{ c.label }}</th></tr>
</template>
<template #item="{ item: index }">
<td>{{ row(index).id }}</td>
<td>{{ row(index).name }}</td>
<td>{{ row(index).email }}</td>
</template>
</CeriousScroll>
#header slot renders into the engine's <thead> (same <table> as the rows → native column alignment, frozen header) and stays reactive.#item slot must return <td>s. They render into the row's <tr> via a display: contents wrapper that isolates Vue's renderer from the engine's row recycling.table.autoSizeColumns measures column widths once and pins them (auto-sized + stable); or use table.columnWidths. Variable row heights work as usual.border-collapse: separate and an opaque <thead> background (see the core README's Table Layout notes).These are engine options, set through options and forwarded to the core
unchanged. They combine with any layout unless noted.
| Option | What it does |
|---|---|
sticky | Pins one dataset row to the top while you are inside its section. The pinned element is drawn outside the recycler, so it survives its own row scrolling out of the mounted window. |
snap | Settles the camera on a row boundary after scrolling stops. |
infinite | Calls onLoadMore as a threshold near an edge is crossed, once per approach. In Vue you grow the dataset by assigning a longer array; the camera does not move. |
aria | Writes aria-setsize and aria-posinset from the real dataset, so a screen reader announces "item 40,112 of 500,000" rather than the size of the mounted window. Opt-in. |
direction | 'ltr', 'rtl' or 'auto' to read the host's own computed direction. |
ssr | hydrate: true adopts pre-rendered rows on the first render instead of clearing them, matched by data-element-index. |
<CeriousScroll
:items="rows"
:options="{
sticky: { resolve: (first) => sectionOf[first] ?? null, className: 'is-pinned' },
snap: { enabled: true, align: 'nearest', tolerance: 2 },
infinite: { threshold: 20, edges: 'end', onLoadMore },
aria: { enabled: true, label: 'Search results' },
direction: 'auto',
}"
>
<template #item="{ item: row }"><Row :row="row" /></template>
</CeriousScroll>
options is read once, at creation. To flip one of these at runtime, re-key the
component.
sticky in VueThe engine renders the pinned header through your own item slot, so a section
header and that same row scrolling past are one piece of markup. The binding
gives the pinned element its own reactive mount, kept apart from the recycled
rows, which is what lets it survive its row leaving the mounted window.
Because one index is drawn in two places at once, the item slot must be
idempotent: a pure function of the item, with no side effects. It already had
to be for Masonry's measurement probe.
infinite in VueReturn the promise. The callback fires once per approach and re-arms when the window leaves the threshold; while a returned promise is pending no further call is made, which is what stops a slow endpoint being asked again on the next frame.
function onLoadMore(ctx: InfiniteLoadContext) {
return fetch(`/api/rows?after=${cursor}`)
.then((r) => r.json())
.then((page) => {
rows.value = rows.value.concat(page.items);
});
}
ssr in VueServer-render with the same row component the client uses, wrap each row in an
element carrying data-element-index, and put them inside an element marked
data-cerious-scroll-content.
Use the composable rather than the component for this: <CeriousScroll>
renders its own children into the container, so markup placed there by other
means is markup Vue does not track, and its next patch walks into nodes that
have moved. The composable hands back a bare container instead.
<script setup lang="ts">
const { containerRef } = useCeriousScroll({
totalElements: total,
getItem: (index) => index,
renderItem: (index) => h(Row, { index }),
options: { ssr: { hydrate: true } },
});
</script>
<template>
<div ref="containerRef" class="feed" />
</template>
The module touches no DOM at import time, so importing it in a server bundle is safe.
render()
so the engine measures real offsetHeight. Later size changes are picked up by
the engine's built-in ResizeObserver.options are read at creation. Changing options after mount has no
effect; remount (e.g. with a :key) to apply new engine options.items array on every edit don't trigger a full viewport re-measure.recalculate() (on the template ref, or from the composable
result) right after the change to drop the height cache and re-measure. Don't
call it on routine edits: a single cell edit keeps its row's size, and the
engine's built-in ResizeObserver picks up any incidental resize on its own.Licensed by Cerious DevTech LLC under the MIT License (see LICENSE-MIT).
32 commits
TypeScript
100.0%