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 your framework's design system (Fluent UI, Material UI, Vuetify, PrimeNG, etc.), click "Open in online demo" below the demo.
Quick Example
- React
- Angular
- Vue
- Vanilla JS
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> - Material UI (MUI v7):
from '@alaarab/ogrid-react-material'- wrap in<ThemeProvider>
import { Component } from '@angular/core';
import { OGridComponent, type IColumnDef } from '@alaarab/ogrid-angular-material';
interface Row {
id: number;
name: string;
value: number;
}
const data: Row[] = Array.from({ length: 10_000 }, (_, i) => ({
id: i + 1,
name: `Row ${i + 1}`,
value: Math.round(Math.random() * 10000),
}));
@Component({
standalone: true,
imports: [OGridComponent],
template: `<ogrid [props]="gridProps" />`
})
export class GridComponent {
gridProps = {
columns: [
{ columnId: 'id', name: 'ID', type: 'numeric' },
{ columnId: 'name', name: 'Name' },
{ columnId: 'value', name: 'Value', type: 'numeric',
valueFormatter: (v: unknown) => `$${Number(v).toLocaleString()}` },
] as IColumnDef<Row>[],
data,
getRowId: (item: Row) => item.id,
virtualScroll: { enabled: true, rowHeight: 36 },
statusBar: true,
};
}
Same component API across Angular packages. To switch, just change the import:
- Radix (CDK):
from '@alaarab/ogrid-angular-radix'(default, lightweight) - Angular Material:
from '@alaarab/ogrid-angular-material' - PrimeNG:
from '@alaarab/ogrid-angular-primeng'
All components are standalone - no NgModule required.
<script setup lang="ts">
import { OGrid, type IColumnDef } from '@alaarab/ogrid-vue-vuetify';
interface Row {
id: number;
name: string;
value: number;
}
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()}` },
];
const gridProps = {
columns,
data,
getRowId: (item: Row) => item.id,
virtualScroll: { enabled: true, rowHeight: 36 },
statusBar: true,
};
</script>
<template>
<OGrid :gridProps="gridProps" />
</template>
Same component API across Vue packages. To switch, just change the import:
- Radix (Headless UI):
from '@alaarab/ogrid-vue-radix'(default, lightweight) - Vuetify:
from '@alaarab/ogrid-vue-vuetify'- wrap in<v-app>for theming - PrimeVue:
from '@alaarab/ogrid-vue-primevue'
import { OGrid } from '@alaarab/ogrid-js';
import '@alaarab/ogrid-js/styles';
const data = Array.from({ length: 10_000 }, (_, i) => ({
id: i + 1,
name: `Row ${i + 1}`,
value: Math.round(Math.random() * 10000),
}));
const grid = new OGrid(document.getElementById('grid'), {
columns: [
{ columnId: 'id', name: 'ID', type: 'numeric' },
{ columnId: 'name', name: 'Name' },
{ columnId: 'value', name: 'Value', type: 'numeric',
valueFormatter: (v) => `$${Number(v).toLocaleString()}` },
],
data,
getRowId: (r) => r.id,
virtualScroll: { enabled: true, rowHeight: 36 },
statusBar: true,
});
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.
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
- Angular
- Vue
- Vanilla JS
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>
);
}
import { Component } from '@angular/core';
import { OGridComponent, OGridService } from '@alaarab/ogrid-angular-material';
@Component({
standalone: true,
imports: [OGridComponent],
template: `
<button (click)="scrollToTop()">Scroll to Top</button>
<button (click)="scrollToMiddle()">Scroll to Row 5000</button>
<button (click)="scrollToBottom()">Scroll to Bottom</button>
<ogrid [props]="gridProps" />
`,
})
export class GridComponent {
constructor(private gridService: OGridService) {}
scrollToTop() { this.gridService.scrollToRow(0); }
scrollToMiddle() { this.gridService.scrollToRow(4999, { align: 'center' }); }
scrollToBottom() { this.gridService.scrollToRow(9999, { align: 'end' }); }
gridProps = {
columns,
data,
getRowId: (item: Row) => item.id,
virtualScroll: { enabled: true, rowHeight: 36 },
};
}
<script setup lang="ts">
import { ref } from 'vue';
import { OGrid, type IOGridApi } from '@alaarab/ogrid-vue-vuetify';
const gridRef = ref<IOGridApi<Row> | null>(null);
const scrollToTop = () => gridRef.value?.scrollToRow(0);
const scrollToMiddle = () => gridRef.value?.scrollToRow(4999, { align: 'center' });
const scrollToBottom = () => gridRef.value?.scrollToRow(9999, { align: 'end' });
</script>
<template>
<button @click="scrollToTop">Scroll to Top</button>
<button @click="scrollToMiddle">Scroll to Row 5000</button>
<button @click="scrollToBottom">Scroll to Bottom</button>
<OGrid :gridProps="gridProps" />
</template>
import { OGrid } from '@alaarab/ogrid-js';
import '@alaarab/ogrid-js/styles';
const grid = new OGrid(document.getElementById('grid'), {
columns,
data,
getRowId: (r) => r.id,
virtualScroll: { enabled: true, rowHeight: 36 },
});
const api = grid.getApi();
// Scroll to the first row
api.scrollToRow(0);
// Scroll to row 5000, centered in the viewport
api.scrollToRow(4999, { align: 'center' });
// Scroll to the last row
api.scrollToRow(9999, { align: 'end' });
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
- Angular
- Vue
- Vanilla JS
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> - Material UI (MUI v7):
from '@alaarab/ogrid-react-material'- wrap in<ThemeProvider>
import { Component } from '@angular/core';
import { OGridComponent, type IColumnDef } from '@alaarab/ogrid-angular-material';
interface Row {
id: number;
[key: string]: number | string;
}
@Component({
standalone: true,
imports: [OGridComponent],
template: `<ogrid [props]="gridProps" />`
})
export class GridComponent {
gridProps = {
columns: [
{ 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,
})),
] as IColumnDef<Row>[],
data,
getRowId: (item: Row) => item.id,
virtualScroll: { enabled: true, rowHeight: 36, columns: true, columnOverscan: 2 },
};
}
Same component API across Angular packages. To switch, just change the import:
- Radix (CDK):
from '@alaarab/ogrid-angular-radix'(default, lightweight) - Angular Material:
from '@alaarab/ogrid-angular-material' - PrimeNG:
from '@alaarab/ogrid-angular-primeng'
All components are standalone - no NgModule required.
<script setup lang="ts">
import { OGrid, type IColumnDef } from '@alaarab/ogrid-vue-vuetify';
interface Row {
id: number;
[key: string]: number | string;
}
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,
})),
];
const gridProps = {
columns,
data,
getRowId: (item: Row) => item.id,
virtualScroll: { enabled: true, rowHeight: 36, columns: true, columnOverscan: 2 },
};
</script>
<template>
<OGrid :gridProps="gridProps" />
</template>
Same component API across Vue packages. To switch, just change the import:
- Radix (Headless UI):
from '@alaarab/ogrid-vue-radix'(default, lightweight) - Vuetify:
from '@alaarab/ogrid-vue-vuetify'- wrap in<v-app>for theming - PrimeVue:
from '@alaarab/ogrid-vue-primevue'
import { OGrid } from '@alaarab/ogrid-js';
import '@alaarab/ogrid-js/styles';
const columns = [
{ columnId: 'id', name: 'ID', type: 'numeric', pinned: 'left', defaultWidth: 80 },
...Array.from({ length: 49 }, (_, i) => ({
columnId: `col${i}`,
name: `Column ${i + 1}`,
type: 'numeric',
})),
];
const grid = new OGrid(document.getElementById('grid'), {
columns,
data,
getRowId: (r) => r.id,
virtualScroll: { enabled: true, rowHeight: 36, columns: true, columnOverscan: 2 },
});
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. |
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. |
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