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.
npm install @ceriousdevtech/react-cerious-scroll @ceriousdevtech/cerious-scroll
react and react-dom (>= 18) are peer dependencies.
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 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.
<CeriousScroll
totalElements={100_000_000}
getItem={(index) => loadRow(index)}
renderItem={(row, index) => <Row data={row} index={index} />}
style={{ height: 600 }}
/>
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>
);
}
| Prop | Type | Description |
|---|---|---|
renderItem | (item, index) => ReactNode | Required. Renders one row. item is undefined if no data source is given. |
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. |
tableHeader | ReactNode | Table mode only. A <tr> of <th>s rendered into the engine's <thead> (see Table layout). |
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. |
onViewportChange | (detail) => void | Normalized viewport-change callback. |
onMeasuredViewport | (range) => void | Measured range after each render pass. |
onReady | (scroller) => void | The underlying engine instance, once ready. |
className / style | string / CSSProperties | Applied to the scroll container (set a height!). |
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
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.
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.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 React you grow the dataset by setting state; 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}
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 ReactThe 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 ReactReturn 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 ReactServer-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.
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.items array on every
edit don't trigger a full viewport re-measure.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.Licensed by Cerious DevTech LLC under the MIT License (see LICENSE-MIT).
29 commits
TypeScript
100.0%
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.
npm install @ceriousdevtech/react-cerious-scroll @ceriousdevtech/cerious-scroll
react and react-dom (>= 18) are peer dependencies.
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 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.
<CeriousScroll
totalElements={100_000_000}
getItem={(index) => loadRow(index)}
renderItem={(row, index) => <Row data={row} index={index} />}
style={{ height: 600 }}
/>
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>
);
}
| Prop | Type | Description |
|---|---|---|
renderItem | (item, index) => ReactNode | Required. Renders one row. item is undefined if no data source is given. |
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. |
tableHeader | ReactNode | Table mode only. A <tr> of <th>s rendered into the engine's <thead> (see Table layout). |
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. |
onViewportChange | (detail) => void | Normalized viewport-change callback. |
onMeasuredViewport | (range) => void | Measured range after each render pass. |
onReady | (scroller) => void | The underlying engine instance, once ready. |
className / style | string / CSSProperties | Applied to the scroll container (set a height!). |
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
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.
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.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 React you grow the dataset by setting state; 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}
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 ReactThe 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 ReactReturn 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 ReactServer-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.
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.items array on every
edit don't trigger a full viewport re-measure.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.Licensed by Cerious DevTech LLC under the MIT License (see LICENSE-MIT).
29 commits
TypeScript
100.0%