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-scrollreact 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/distThe 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!). |
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 engineSet 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>
</>
);
}}
/>tableHeaderis portaled into the engine's<thead>: the same<table>as the rows, so columns align natively and the header stays frozen.renderItemmust return<td>s (a fragment of cells). They're rendered into the row's<tr>via adisplay: contentswrapper, so React fully owns them and the engine's row recycling can't tear them out.table.autoSizeColumnsmeasures column widths once and pins them: auto-sized but stable (no jitter, no manual widths). Or usetable.columnWidths. Variable row heights work as usual.- CSS:
border-collapse: separateand 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.
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 } }}
/>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)));
}, []);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.
- No height estimation. Rows are committed with
flushSyncso the engine measures realoffsetHeight. Later size changes are picked up by the engine's built-inResizeObserver. optionsare read at creation. Changingoptionsafter mount has no effect; remount (e.g. with akey) to apply new engine options. This applies tosticky,snap,infinite,aria,directionandssrtoo.renderItemmust be idempotent. It is called for the measurement probe in Masonry, and for both copies of a pinned row whenstickyis 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
itemsarray 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 theref, 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-inResizeObserverpicks up any incidental resize on its own.
Licensed by Cerious DevTech LLC under the MIT License (see LICENSE-MIT).