ceriousdevtech/react-cerious-scroll

React implementation for Cerious-Scroll

TypeScript

3

29 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/reactjs)

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

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/react-cerious-scroll

License Live Demo

React 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 React portals and committed synchronously, so every row's real height is measured (never estimated): exactly the guarantee that makes CeriousScroll precise. Because rows stay in your React tree, Context / providers work normally.


Installation

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

react and react-dom (>= 18) are peer dependencies.


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 a renderItem render prop.

import { CeriousScroll } from '@ceriousdevtech/react-cerious-scroll';

const items = Array.from({ length: 1_000_000 }, (_, i) => ({ id: i, name: `Item ${i}` }));

export function List() {
  return (
    <CeriousScroll
      items={items}
      renderItem={(item, index) => (
        <div className="row">
          {index}, {item.name}
        </div>
      )}
      style={{ height: 480 }}
    />
  );
}

Variable heights need no configuration, just render rows of whatever height; the engine measures each one.

Without a full array (huge / sparse data)

<CeriousScroll
  totalElements={100_000_000}
  getItem={(index) => loadRow(index)}
  renderItem={(row, index) => <Row data={row} index={index} />}
  style={{ height: 600 }}
/>

Hook

useCeriousScroll gives you full control. Attach containerRef to your scroll element and render portals somewhere in your tree (they attach to their own DOM targets, so placement only affects which React Context they inherit).

import { useCeriousScroll } from '@ceriousdevtech/react-cerious-scroll';

function List() {
  const { containerRef, portals } = useCeriousScroll({
    items,
    renderItem: (item, index) => <Row item={item} index={index} />,
  });

  return (
    <div ref={containerRef} style={{ height: 480, position: 'relative', overflow: 'hidden' }}>
      {portals}
    </div>
  );
}

Component props

PropTypeDescription
renderItem(item, index) => ReactNodeRequired. Renders one row. item is undefined if no data source is given.
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.
tableHeaderReactNodeTable mode only. A <tr> of <th>s rendered into the engine's <thead> (see Table layout).
optionsCeriousScrollOptionsEngine options. Masonry's DOM callback is supplied by the wrapper. Read once at creation.
autoRenderbooleanRe-render on scroll/resize/data changes. Default true.
onViewportChange(detail) => voidNormalized viewport-change callback.
onMeasuredViewport(range) => voidMeasured range after each render pass.
onReady(scroller) => voidThe underlying engine instance, once ready.
className / stylestring / CSSPropertiesApplied to the scroll container (set a height!).

Imperative handle (via ref)

const ref = useRef<CeriousScrollHandle>(null);
// ref.current?.jumpToElement(500);
// ref.current?.jumpToItem(500); // Masonry cards
// ref.current?.scrollToPercentage(50);
// ref.current?.reset();
// ref.current?.render();
// ref.current?.recalculate(); // drop cached heights + re-measure (see Notes)
// ref.current?.scroller;      // the raw engine

Masonry layout

Set layout: 'masonry' and provide Masonry geometry without a DOM renderItem; the wrapper connects your existing React render prop to the core renderer. Supplying getItemHeight selects canonical placement:

<CeriousScroll
  ref={ref}
  className="gallery"
  totalElements={photos.length}
  getItem={(index) => photos[index]}
  options={{
    layout: 'masonry',
    masonry: {
      getItemHeight: (_index, width) => width * 0.75 + 48,
      targetColumnWidth: 280,
      gap: 16,
    },
  }}
  renderItem={(photo, index) => <PhotoCard photo={photo} index={index} />}
/>

Omit getItemHeight for dynamic DOM-measured cards. React static markup is used for the offscreen measurement probe; visible cards remain normal live portals with Context, refs, state, and events.

Use ref.current?.jumpToItem(index, screenOffset?) for card navigation and ref.current?.scroller?.masonryDeterminism to read 'canonical' or 'local'. The demo gallery includes matching 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. Your renderItem returns the row's <td> cells, and tableHeader provides the (declarative, reactive) <thead> row:

import { TABLE_COLUMNS } from './data';

<CeriousScroll
  className="my-scroll"            // give it a height
  totalElements={100_000}
  getItem={(i) => i}
  options={{ layout: 'table', table: { tableClassName: 'my-table', autoSizeColumns: true } }}
  tableHeader={
    <tr>{TABLE_COLUMNS.map((c) => <th key={c.key}>{c.label}</th>)}</tr>
  }
  renderItem={(index) => {
    const row = makeRow(index);
    return (
      <>
        <td>{row.id}</td>
        <td>{row.name}</td>
        <td>{row.email}</td>
      </>
    );
  }}
/>
  • tableHeader is portaled into the engine's <thead>: the same <table> as the rows, so columns align natively and the header stays frozen.
  • renderItem must return <td>s (a fragment of cells). They're rendered into the row's <tr> via a display: contents wrapper, so React fully owns them and the engine's row recycling can't tear them out.
  • table.autoSizeColumns measures column widths once and pins them: auto-sized but stable (no jitter, no manual widths). 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 React you grow the dataset by setting state; 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}
  renderItem={(row, index) => <Row row={row} index={index} />}
  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',
  }}
/>

options is read once, at creation. To flip one of these at runtime, remount with a key.

sticky in React

The engine renders the pinned header through your own renderItem, so a section header and that same row scrolling past are one piece of markup. The binding gives the pinned element its own portal, 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, renderItem must be idempotent: a pure function of (item, index) with no side effects. It already had to be for Masonry's measurement probe.

const sectionOf = useMemo(() => buildSectionIndex(rows), [rows]);

<CeriousScroll
  items={rows}
  renderItem={(row) => (row.kind === 'head' ? <SectionHeader row={row} /> : <ContactRow row={row} />)}
  options={{ sticky: { resolve: (first) => sectionOf[first] ?? null } }}
/>

infinite in React

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.

const onLoadMore = useCallback((ctx: InfiniteLoadContext) => {
  return fetch(`/api/rows?after=${cursorRef.current}`)
    .then((r) => r.json())
    .then((page) => setRows((prev) => prev.concat(page.items)));
}, []);

ssr in React

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 hook rather than the component for this: <CeriousScroll> renders its own children into the container, so markup placed there by other means is markup React does not track, and its next reconciliation walks into nodes that have moved. The hook hands back a bare container with the portals as siblings, so nothing React manages lives inside it.

const { containerRef, portals } = useCeriousScroll({
  totalElements: total,
  getItem: (index) => index,
  renderItem: (index) => <Row index={index} />,
  options: { ssr: { hydrate: true } },
});

return (
  <>
    <div ref={containerRef} className="feed" />
    {portals}
  </>
);

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 flushSync 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. This applies to sticky, snap, infinite, aria, direction and ssr too.
  • renderItem must be idempotent. It is called for the measurement probe in Masonry, and for both copies of a pinned row when sticky is on.
  • 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 (cheap, and your row state is preserved). 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 ref, or from the hook 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/react-cerious-scroll

React implementation for Cerious-Scroll

TypeScript

3

29 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/reactjs)

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

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/react-cerious-scroll

License Live Demo

React 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 React portals and committed synchronously, so every row's real height is measured (never estimated): exactly the guarantee that makes CeriousScroll precise. Because rows stay in your React tree, Context / providers work normally.


Installation

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

react and react-dom (>= 18) are peer dependencies.


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 a renderItem render prop.

import { CeriousScroll } from '@ceriousdevtech/react-cerious-scroll';

const items = Array.from({ length: 1_000_000 }, (_, i) => ({ id: i, name: `Item ${i}` }));

export function List() {
  return (
    <CeriousScroll
      items={items}
      renderItem={(item, index) => (
        <div className="row">
          {index}, {item.name}
        </div>
      )}
      style={{ height: 480 }}
    />
  );
}

Variable heights need no configuration, just render rows of whatever height; the engine measures each one.

Without a full array (huge / sparse data)

<CeriousScroll
  totalElements={100_000_000}
  getItem={(index) => loadRow(index)}
  renderItem={(row, index) => <Row data={row} index={index} />}
  style={{ height: 600 }}
/>

Hook

useCeriousScroll gives you full control. Attach containerRef to your scroll element and render portals somewhere in your tree (they attach to their own DOM targets, so placement only affects which React Context they inherit).

import { useCeriousScroll } from '@ceriousdevtech/react-cerious-scroll';

function List() {
  const { containerRef, portals } = useCeriousScroll({
    items,
    renderItem: (item, index) => <Row item={item} index={index} />,
  });

  return (
    <div ref={containerRef} style={{ height: 480, position: 'relative', overflow: 'hidden' }}>
      {portals}
    </div>
  );
}

Component props

PropTypeDescription
renderItem(item, index) => ReactNodeRequired. Renders one row. item is undefined if no data source is given.
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.
tableHeaderReactNodeTable mode only. A <tr> of <th>s rendered into the engine's <thead> (see Table layout).
optionsCeriousScrollOptionsEngine options. Masonry's DOM callback is supplied by the wrapper. Read once at creation.
autoRenderbooleanRe-render on scroll/resize/data changes. Default true.
onViewportChange(detail) => voidNormalized viewport-change callback.
onMeasuredViewport(range) => voidMeasured range after each render pass.
onReady(scroller) => voidThe underlying engine instance, once ready.
className / stylestring / CSSPropertiesApplied to the scroll container (set a height!).

Imperative handle (via ref)

const ref = useRef<CeriousScrollHandle>(null);
// ref.current?.jumpToElement(500);
// ref.current?.jumpToItem(500); // Masonry cards
// ref.current?.scrollToPercentage(50);
// ref.current?.reset();
// ref.current?.render();
// ref.current?.recalculate(); // drop cached heights + re-measure (see Notes)
// ref.current?.scroller;      // the raw engine

Masonry layout

Set layout: 'masonry' and provide Masonry geometry without a DOM renderItem; the wrapper connects your existing React render prop to the core renderer. Supplying getItemHeight selects canonical placement:

<CeriousScroll
  ref={ref}
  className="gallery"
  totalElements={photos.length}
  getItem={(index) => photos[index]}
  options={{
    layout: 'masonry',
    masonry: {
      getItemHeight: (_index, width) => width * 0.75 + 48,
      targetColumnWidth: 280,
      gap: 16,
    },
  }}
  renderItem={(photo, index) => <PhotoCard photo={photo} index={index} />}
/>

Omit getItemHeight for dynamic DOM-measured cards. React static markup is used for the offscreen measurement probe; visible cards remain normal live portals with Context, refs, state, and events.

Use ref.current?.jumpToItem(index, screenOffset?) for card navigation and ref.current?.scroller?.masonryDeterminism to read 'canonical' or 'local'. The demo gallery includes matching 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. Your renderItem returns the row's <td> cells, and tableHeader provides the (declarative, reactive) <thead> row:

import { TABLE_COLUMNS } from './data';

<CeriousScroll
  className="my-scroll"            // give it a height
  totalElements={100_000}
  getItem={(i) => i}
  options={{ layout: 'table', table: { tableClassName: 'my-table', autoSizeColumns: true } }}
  tableHeader={
    <tr>{TABLE_COLUMNS.map((c) => <th key={c.key}>{c.label}</th>)}</tr>
  }
  renderItem={(index) => {
    const row = makeRow(index);
    return (
      <>
        <td>{row.id}</td>
        <td>{row.name}</td>
        <td>{row.email}</td>
      </>
    );
  }}
/>
  • tableHeader is portaled into the engine's <thead>: the same <table> as the rows, so columns align natively and the header stays frozen.
  • renderItem must return <td>s (a fragment of cells). They're rendered into the row's <tr> via a display: contents wrapper, so React fully owns them and the engine's row recycling can't tear them out.
  • table.autoSizeColumns measures column widths once and pins them: auto-sized but stable (no jitter, no manual widths). 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 React you grow the dataset by setting state; 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}
  renderItem={(row, index) => <Row row={row} index={index} />}
  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',
  }}
/>

options is read once, at creation. To flip one of these at runtime, remount with a key.

sticky in React

The engine renders the pinned header through your own renderItem, so a section header and that same row scrolling past are one piece of markup. The binding gives the pinned element its own portal, 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, renderItem must be idempotent: a pure function of (item, index) with no side effects. It already had to be for Masonry's measurement probe.

const sectionOf = useMemo(() => buildSectionIndex(rows), [rows]);

<CeriousScroll
  items={rows}
  renderItem={(row) => (row.kind === 'head' ? <SectionHeader row={row} /> : <ContactRow row={row} />)}
  options={{ sticky: { resolve: (first) => sectionOf[first] ?? null } }}
/>

infinite in React

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.

const onLoadMore = useCallback((ctx: InfiniteLoadContext) => {
  return fetch(`/api/rows?after=${cursorRef.current}`)
    .then((r) => r.json())
    .then((page) => setRows((prev) => prev.concat(page.items)));
}, []);

ssr in React

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 hook rather than the component for this: <CeriousScroll> renders its own children into the container, so markup placed there by other means is markup React does not track, and its next reconciliation walks into nodes that have moved. The hook hands back a bare container with the portals as siblings, so nothing React manages lives inside it.

const { containerRef, portals } = useCeriousScroll({
  totalElements: total,
  getItem: (index) => index,
  renderItem: (index) => <Row index={index} />,
  options: { ssr: { hydrate: true } },
});

return (
  <>
    <div ref={containerRef} className="feed" />
    {portals}
  </>
);

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 flushSync 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. This applies to sticky, snap, infinite, aria, direction and ssr too.
  • renderItem must be idempotent. It is called for the measurement probe in Masonry, and for both copies of a pinned row when sticky is on.
  • 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 (cheap, and your row state is preserved). 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 ref, or from the hook 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%