Skip to main content

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

10,000 rows - only visible rows are in the DOM
Live
Try it in your framework

The demo above uses Radix UI for styling. To see this feature with your framework's design system (Fluent UI, Material UI, Vuetify, PrimeNG, etc.), click "Open in online demo" below the demo.

Quick Example

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
/>
);
}
Switching UI libraries

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>
  • Material UI (MUI v7): from '@alaarab/ogrid-react-material' - wrap in <ThemeProvider>

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:

  1. A top spacer pushes the first visible row into the correct scroll position.
  2. Only rows within the viewport (plus overscan rows above and below) are rendered as real DOM elements.
  3. A bottom spacer maintains the full scrollable height so the scrollbar reflects the true data size.
  4. 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.

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
// ...
/>
caution

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:

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:

AlignBehavior
'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:

RowsWithout virtual scrollWith virtual scroll (overscan=5)
100100 <tr> elements100 (passthrough, no overhead)
1,0001,000 <tr> elements~30 <tr> elements
10,00010,000 <tr> elements~30 <tr> elements
100,000100,000 <tr> elements~30 <tr> elements

Key implementation details:

  • RAF throttling -- Scroll events are batched via requestAnimationFrame so 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:

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

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 }}
/>
);
}
Switching UI libraries

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>
  • Material UI (MUI v7): from '@alaarab/ogrid-react-material' - wrap in <ThemeProvider>

How Column Virtualization Works

  1. On each horizontal scroll event, the grid calculates which columns fall within the visible horizontal range.
  2. Only those columns - plus columnOverscan extra columns on each side - are rendered as real <td> elements.
  3. A left spacer <td> and a right spacer <td> maintain the correct total table width so the scrollbar reflects the true column span.
  4. 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' },
// ...
];
caution

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

FieldTypeDefaultDescription
enabledbooleanfalseEnable row virtual scrolling. Must be set to true to activate.
rowHeightnumber36Fixed height of each row in pixels. Required when row virtualization is enabled.
overscannumber5Extra rows rendered above and below the visible area.
columnsbooleanfalseEnable column virtualization. Renders only columns visible in the horizontal viewport.
columnOverscannumber2Extra columns rendered beyond the visible horizontal range on each side.

Grid API Methods

MethodSignatureDescription
scrollToRow(index: number, options?: { align?: 'start' | 'center' | 'end' }) => voidScroll to a specific row by its index in the data array.
tip

For the best experience with virtual scrolling, ensure your CSS row height matches the rowHeight value. Mismatched heights cause scroll position drift.

  • 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