Virtual Scrolling
Render tens of thousands of rows - and hundreds of columns - without degrading performance. Virtual scrolling only mounts the content currently visible in the viewport (plus a small overscan buffer), keeping the DOM lightweight regardless of dataset size.
OGrid supports two independent virtualization axes that can be combined:
- Row virtualization - only visible rows are rendered. Available for all datasets.
- Column virtualization - only visible columns are rendered. Opt-in, useful for wide grids with many columns.
Live Demo
The demo above uses Radix UI for styling. To see this feature with the Fluent UI implementation, click "Open in online demo" below the demo.
Quick Example
- React
import { OGrid } from '@alaarab/ogrid-react-radix';
import type { IColumnDef } from '@alaarab/ogrid-react-radix';
interface Row {
id: number;
name: string;
value: number;
}
// Generate 10,000 rows
const data: Row[] = Array.from({ length: 10_000 }, (_, i) => ({
id: i + 1,
name: `Row ${i + 1}`,
value: Math.round(Math.random() * 10000),
}));
const columns: IColumnDef<Row>[] = [
{ columnId: 'id', name: 'ID', type: 'numeric' },
{ columnId: 'name', name: 'Name' },
{ columnId: 'value', name: 'Value', type: 'numeric',
valueFormatter: (v) => `$${Number(v).toLocaleString()}` },
];
function App() {
return (
<OGrid
columns={columns}
data={data}
getRowId={(r) => r.id}
virtualScroll={{ enabled: true, rowHeight: 36 }}
statusBar
/>
);
}
The OGrid component has the same props across all React UI packages. To switch, just change the import:
- Radix (lightweight, default):
from '@alaarab/ogrid-react-radix' - Fluent UI (Microsoft 365 / SPFx):
from '@alaarab/ogrid-react-fluent'- wrap in<FluentProvider>
All 10,000 rows are tracked (check the status bar), but only the visible rows plus a small overscan buffer are actually in the DOM.
How It Works
Pass a virtualScroll configuration object to enable virtualization:
<OGrid
virtualScroll={{ enabled: true, rowHeight: 36 }}
// ...other props
/>
The grid replaces the standard row rendering with a virtualized strategy:
- A top spacer pushes the first visible row into the correct scroll position.
- Only rows within the viewport (plus
overscanrows above and below) are rendered as real DOM elements. - A bottom spacer maintains the full scrollable height so the scrollbar reflects the true data size.
- On scroll, a
requestAnimationFrame-throttled handler recalculates which rows are visible and swaps them in.
Auto-Enable Threshold
Virtual scrolling automatically becomes a no-op when the dataset has fewer than 100 rows. Below that threshold, all rows render normally -- there is no performance benefit from virtualizing a small list, and the passthrough avoids unnecessary spacer elements.
Full-Dataset Virtualization (No Pagination)
By default, virtual scrolling windows the rows of the current page -- the grid still paginates, and each page is a separate scroll region. For a large in-memory dataset where paging would be an arbitrary interruption, set paginate: false to virtual-scroll the entire dataset in one continuous viewport:
<OGrid
columns={columns}
data={data} // e.g. 1,000,000 rows
getRowId={(r) => r.id}
virtualScroll={{ enabled: true, rowHeight: 36, paginate: false }}
statusBar
/>
With paginate: false:
- Every row is a candidate for rendering -- the grid scrolls through the whole dataset, not one page at a time.
- Pagination controls are hidden (there are no pages to navigate).
- Sorting and filtering still apply across the full dataset.
paginate only applies to client-side grids (data). Server-side grids (dataSource) always page through the data source -- see Server-Side Data for the windowed/lazy mode that streams very large datasets on demand.
When mounting the lower-level DataGridTable directly (without OGrid), give its parent a definite height so the virtual-scroll viewport can be measured. A virtual-scroll DataGridTable falls back to a min-height of 480px (override with the --ogrid-virtual-scroll-min-height CSS variable) so it never collapses to the header height.
Custom Row Height
The rowHeight property sets the fixed pixel height for every row. Choose a value that matches your CSS row height:
<OGrid
virtualScroll={{ enabled: true, rowHeight: 48 }} // taller rows
// ...
/>
Virtual scrolling requires a fixed row height. Variable-height rows are not supported -- every row must be exactly rowHeight pixels tall.
Overscan
The overscan property controls how many extra rows are rendered above and below the visible area. The default is 5 rows. Increasing overscan reduces visual flicker during fast scrolling but renders more DOM nodes:
<OGrid
virtualScroll={{ enabled: true, rowHeight: 36, overscan: 10 }}
// ...
/>
Programmatic Scrolling
Use the Grid API scrollToRow method to scroll to a specific row by index:
- React
import { useRef } from 'react';
import { OGrid, type IOGridApi } from '@alaarab/ogrid-react-radix';
function App() {
const ref = useRef<IOGridApi<Row>>(null);
return (
<div>
<button onClick={() => ref.current?.scrollToRow(0)}>
Scroll to Top
</button>
<button onClick={() => ref.current?.scrollToRow(4999, { align: 'center' })}>
Scroll to Row 5000
</button>
<button onClick={() => ref.current?.scrollToRow(9999, { align: 'end' })}>
Scroll to Bottom
</button>
<OGrid
ref={ref}
columns={columns}
data={data}
getRowId={(r) => r.id}
virtualScroll={{ enabled: true, rowHeight: 36 }}
/>
</div>
);
}
The align option controls where the target row appears in the viewport:
| Align | Behavior |
|---|---|
'start' (default) | Row appears at the top of the viewport |
'center' | Row appears in the middle of the viewport |
'end' | Row appears at the bottom of the viewport |
Keyboard Navigation
Virtual scrolling integrates with keyboard navigation automatically. When the user moves the active cell with arrow keys, Tab, or Enter, the grid scrolls to keep the active row visible. No additional configuration is needed.
Performance
Virtual scrolling dramatically reduces DOM node count for large datasets:
| Rows | Without virtual scroll | With virtual scroll (overscan=5) |
|---|---|---|
| 100 | 100 <tr> elements | 100 (passthrough, no overhead) |
| 1,000 | 1,000 <tr> elements | ~30 <tr> elements |
| 10,000 | 10,000 <tr> elements | ~30 <tr> elements |
| 100,000 | 100,000 <tr> elements | ~30 <tr> elements |
Key implementation details:
- RAF throttling -- Scroll events are batched via
requestAnimationFrameso range recalculation happens at most once per frame. - Spacer rows -- Top and bottom spacer elements maintain the correct total scroll height without rendering every row.
- Overscan buffer -- Extra rows above and below the viewport prevent blank flashes during fast scrolling.
- Passthrough threshold -- Datasets under 100 rows skip virtualization entirely, avoiding unnecessary spacer elements for small lists.
Column Virtualization
For grids with many columns, column virtualization renders only the columns visible in the horizontal viewport. This keeps the DOM width-independent of the total column count.
Enable it by setting columns: true inside the virtualScroll config:
<OGrid
virtualScroll={{ enabled: true, rowHeight: 36, columns: true }}
// ...
/>
Row virtualization and column virtualization are independent. You can enable either or both:
| Config | Behavior |
|---|---|
{ enabled: true, rowHeight: 36 } | Row virtualization only |
{ enabled: true, rowHeight: 36, columns: true } | Row + column virtualization |
{ enabled: false, columns: true } | Column virtualization only (static rows) |
Quick Example
- React
import { OGrid } from '@alaarab/ogrid-react-radix';
import type { IColumnDef } from '@alaarab/ogrid-react-radix';
interface Row {
id: number;
[key: string]: number | string;
}
// Generate 50 columns
const columns: IColumnDef<Row>[] = [
{ columnId: 'id', name: 'ID', type: 'numeric', pinned: 'left', defaultWidth: 80 },
...Array.from({ length: 49 }, (_, i) => ({
columnId: `col${i}`,
name: `Column ${i + 1}`,
type: 'numeric' as const,
})),
];
function App() {
return (
<OGrid
columns={columns}
data={data}
getRowId={(r) => r.id}
virtualScroll={{ enabled: true, rowHeight: 36, columns: true, columnOverscan: 2 }}
/>
);
}
The OGrid component has the same props across all React UI packages. To switch, just change the import:
- Radix (lightweight, default):
from '@alaarab/ogrid-react-radix' - Fluent UI (Microsoft 365 / SPFx):
from '@alaarab/ogrid-react-fluent'- wrap in<FluentProvider>
How Column Virtualization Works
- On each horizontal scroll event, the grid calculates which columns fall within the visible horizontal range.
- Only those columns - plus
columnOverscanextra columns on each side - are rendered as real<td>elements. - A left spacer
<td>and a right spacer<td>maintain the correct total table width so the scrollbar reflects the true column span. - Pinned columns always render, regardless of scroll position.
Column Overscan
The columnOverscan option controls how many extra columns are rendered beyond the visible area on each side. The default is 2. Increasing it reduces blank flashes when scrolling quickly across many columns:
// Render 4 extra columns on each side of the visible range
virtualScroll={{ enabled: true, rowHeight: 36, columns: true, columnOverscan: 4 }}
Pinned Columns with Column Virtualization
Pinned columns are excluded from virtualization and always rendered. This ensures sticky columns are never hidden during horizontal scroll:
const columns = [
// This column always renders - not subject to column virtualization
{ columnId: 'name', name: 'Name', pinned: 'left' },
// These columns are virtualized
{ columnId: 'col1', name: 'Column 1' },
{ columnId: 'col2', name: 'Column 2' },
// ...
];
Column virtualization requires that all columns have a defined width (via defaultWidth). Columns without a width may cause layout shifts during horizontal scroll. Set a reasonable defaultWidth on each column definition.
Props Reference
virtualScroll
| Field | Type | Default | Description |
|---|---|---|---|
enabled | boolean | false | Enable row virtual scrolling. Must be set to true to activate. |
paginate | boolean | true | When false, bypass pagination and virtual-scroll the entire dataset in one viewport. Pagination controls are hidden. Client-side grids only. |
rowHeight | number | 36 | Fixed height of each row in pixels. Required when row virtualization is enabled. |
overscan | number | 5 | Extra rows rendered above and below the visible area. |
threshold | number | 100 | Minimum row count before virtualization activates. Below this, all rows render. |
columns | boolean | false | Enable column virtualization. Renders only columns visible in the horizontal viewport. |
columnOverscan | number | 2 | Extra columns rendered beyond the visible horizontal range on each side. |
Grid API Methods
| Method | Signature | Description |
|---|---|---|
scrollToRow | (index: number, options?: { align?: 'start' | 'center' | 'end' }) => void | Scroll to a specific row by its index in the data array. |
For the best experience with virtual scrolling, ensure your CSS row height matches the rowHeight value. Mismatched heights cause scroll position drift.
Related
- Pagination -- alternative to virtual scrolling for large datasets
- Server-Side Data -- combine with virtual scrolling for server-paged data
- Keyboard Navigation -- auto-scrolls during virtual scrolling
- Status Bar -- shows total row count even when most rows are virtualized
- Performance -- worker-based sort/filter and CSS containment for large datasets