ceriousdevtech/vue-cerious-scroll

Vue implementation for Cerious-Scroll

TypeScript

20

32 commits

updated Sep 21, 2026

See the code

See what people are saying

SourceMessageScoreDate

Cerious-Scroll: Extremely Fast Dynamic Height virtual scrolling without scrollbar drift or blank space (r/webdev)

One of the things I haven’t talked about much with Cerious-Scroll is how it behaves during actual scrolling, especially with variable-height content. From the beginning, the goal was for virtualization to not *look* like virtualization. So even with a large variable-height dataset, you can grab the…

0

Sep 26, 2026

README

@ceriousdevtech/vue-cerious-scroll

License Live Demo

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.


Installation

npm install @ceriousdevtech/vue-cerious-scroll @ceriousdevtech/cerious-scroll

vue (>= 3.3) is a peer dependency.


Demo

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.


Quick start (component)

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.

Without a full array (huge / sparse data)

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

Composable

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>

renderItem returns a Vue VNodeChild (use h(...), or render JSX/TSX).


Component props

PropTypeDescription
itemsreadonly TItem[]Optional data array. totalElements defaults to items.length.
totalElementsnumberTotal item count. Required if items is omitted.
getItem(index) => TItemLazy item getter for large/sparse datasets.
renderItem(item, index) => VNodeChildRender prop alternative to the #item scoped slot.
optionsCeriousScrollOptionsEngine options. Masonry's DOM callback is supplied by the wrapper. Read once at creation.
autoRenderbooleanRe-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!).

Events

EventPayloadDescription
viewport-changeCeriousViewportChangeDetailNormalized viewport-change (wheel/touch/keyboard/scrollbar).
measured-viewportMeasuredViewportRangeMeasured range after each render pass.
readyCeriousScrollEngineThe underlying engine instance, once ready.

Imperative API (via template 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

Masonry layout

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.

Table layout

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>
  • The #header slot renders into the engine's <thead> (same <table> as the rows → native column alignment, frozen header) and stays reactive.
  • The #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.
  • CSS: border-collapse: separate and an opaque <thead> background (see the core README's Table Layout notes).

Capabilities

These are engine options, set through options and forwarded to the core unchanged. They combine with any layout unless noted.

OptionWhat it does
stickyPins 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.
snapSettles the camera on a row boundary after scrolling stops.
infiniteCalls 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.
ariaWrites 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.
ssrhydrate: 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 Vue

The 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 Vue

Return 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 Vue

Server-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.


Notes

  • No height estimation. Rows are committed with Vue's synchronous 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.
  • Changing the item count updates lists/tables in place. Masonry recreates its card-count-derived segment layout. Mutating items without changing the count just re-renders the content in place (cheap; Vue patches each row, so focus/selection survive), it does not discard cached heights, so editable grids that produce a new items array on every edit don't trigger a full viewport re-measure.
  • If every rendered row's height changes at once (e.g. a density/layout switch) the cached heights become stale and rows can misalign until the next scroll. Call 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.

License

Licensed by Cerious DevTech LLC under the MIT License (see LICENSE-MIT).

📧 info@ceriousdevtech.com

Contributors

ceriousdevtech/vue-cerious-scroll

Vue implementation for Cerious-Scroll

TypeScript

20

32 commits

updated Sep 21, 2026

See the code

See what people are saying

SourceMessageScoreDate

Cerious-Scroll: Extremely Fast Dynamic Height virtual scrolling without scrollbar drift or blank space (r/webdev)

One of the things I haven’t talked about much with Cerious-Scroll is how it behaves during actual scrolling, especially with variable-height content. From the beginning, the goal was for virtualization to not *look* like virtualization. So even with a large variable-height dataset, you can grab the…

0

Sep 26, 2026

README

@ceriousdevtech/vue-cerious-scroll

License Live Demo

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.


Installation

npm install @ceriousdevtech/vue-cerious-scroll @ceriousdevtech/cerious-scroll

vue (>= 3.3) is a peer dependency.


Demo

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.


Quick start (component)

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.

Without a full array (huge / sparse data)

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

Composable

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>

renderItem returns a Vue VNodeChild (use h(...), or render JSX/TSX).


Component props

PropTypeDescription
itemsreadonly TItem[]Optional data array. totalElements defaults to items.length.
totalElementsnumberTotal item count. Required if items is omitted.
getItem(index) => TItemLazy item getter for large/sparse datasets.
renderItem(item, index) => VNodeChildRender prop alternative to the #item scoped slot.
optionsCeriousScrollOptionsEngine options. Masonry's DOM callback is supplied by the wrapper. Read once at creation.
autoRenderbooleanRe-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!).

Events

EventPayloadDescription
viewport-changeCeriousViewportChangeDetailNormalized viewport-change (wheel/touch/keyboard/scrollbar).
measured-viewportMeasuredViewportRangeMeasured range after each render pass.
readyCeriousScrollEngineThe underlying engine instance, once ready.

Imperative API (via template 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

Masonry layout

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.

Table layout

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>
  • The #header slot renders into the engine's <thead> (same <table> as the rows → native column alignment, frozen header) and stays reactive.
  • The #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.
  • CSS: border-collapse: separate and an opaque <thead> background (see the core README's Table Layout notes).

Capabilities

These are engine options, set through options and forwarded to the core unchanged. They combine with any layout unless noted.

OptionWhat it does
stickyPins 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.
snapSettles the camera on a row boundary after scrolling stops.
infiniteCalls 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.
ariaWrites 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.
ssrhydrate: 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 Vue

The 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 Vue

Return 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 Vue

Server-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.


Notes

  • No height estimation. Rows are committed with Vue's synchronous 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.
  • Changing the item count updates lists/tables in place. Masonry recreates its card-count-derived segment layout. Mutating items without changing the count just re-renders the content in place (cheap; Vue patches each row, so focus/selection survive), it does not discard cached heights, so editable grids that produce a new items array on every edit don't trigger a full viewport re-measure.
  • If every rendered row's height changes at once (e.g. a density/layout switch) the cached heights become stale and rows can misalign until the next scroll. Call 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.

License

Licensed by Cerious DevTech LLC under the MIT License (see LICENSE-MIT).

📧 info@ceriousdevtech.com

Contributors

Languages

TypeScript

100.0%