Performance
Large data stays fast when only what is visible is rendered, the server does the querying, and code nobody needs on the first screen loads later. The knobs for each, with the defaults they start from.
Virtualization
Virtual scrolling renders the rows inside the viewport plus an overscan margin. It needs a bounded height — a viewport to be inside of.
| Family | Setting | Default |
|---|---|---|
| Data grid | virtualScroll (shorthand) or scrolling: { mode: 'virtual' | 'infinite', remote, columnRenderingMode } ; rowHeight , overscan , autoRowHeight | standard (off); rowHeight 36, overscan 6 |
| Tree list | virtualScroll , columnRenderingMode , rowHeight , overscan | off |
| Pivot grid | virtualScrolling | off |
| Select box, tag box, autocomplete, multi-column combo box | virtualScroll: true | { itemHeight, overscan } | off; overscan 4 |
| Scheduler (timeline) | virtualScrolling: boolean | 'auto' | 'auto' — on above 50 timeline rows |
| Kanban | virtualScrolling (per-column card windowing), cardHeight | on; cardHeight 112 |
| Gantt | Always on; fixed rowHeight in the config | rowHeight 36 |
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { OgeGrid, OgeColumn } from '@oge-ui/grid';
interface Reading {
id: number;
sensor: string;
value: number;
}
@Component({
selector: 'demo-root',
imports: [OgeGrid, OgeColumn],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<!-- a bounded height is what gives the virtualizer a viewport -->
<oge-grid
[data]="rows"
keyField="id"
[scrolling]="{ mode: 'virtual', columnRenderingMode: 'virtual' }"
[rowHeight]="32"
[overscan]="10"
style="height: 560px"
>
<oge-column field="id" caption="Id" [width]="90" dataType="number" />
<oge-column field="sensor" caption="Sensor" [width]="160" />
<oge-column field="value" caption="Value" [width]="120" dataType="number" />
</oge-grid>
`,
})
export class Demo {
// 100 000 rows: only the rows in view (plus overscan) are in the DOM
protected readonly rows: Reading[] = Array.from({ length: 100_000 }, (_, i) => ({
id: i + 1,
sensor: 'S-' + (i % 250),
value: Math.round(Math.sin(i) * 1000) / 10,
}));
}// app-wide defaults instead of per-grid inputs
import { ApplicationConfig } from '@angular/core';
import { provideOgeGridConfig } from '@oge-ui/grid';
export const appConfig: ApplicationConfig = {
providers: [provideOgeGridConfig({ rowHeight: 32, overscan: 8, filterDebounce: 250 })],
}; Column virtualization renders only the columns in view; it does not combine with pinned columns or column bands, and a column without a numeric width counts with the minimum width. autoRowHeight measures real row heights in virtual mode at some cost — keep a fixed rowHeight when rows are uniform.
Remote data
Pass a DataSource instead of an array and the grid sends its whole query — sort, filter, search, grouping, summaries, page — to it; the source's capabilities say which parts the server handles.
| Source | Use it for | Server does |
|---|---|---|
ArrayDataSource | In-memory rows (what a plain array becomes) | Nothing — everything runs in the browser; implements loadSync |
CustomDataSource | Your own endpoint: one load(options) function | Whatever capabilities says (default: everything) |
CursorDataSource | Cursor-paged APIs ( after=…&limit=50 → { items, nextCursor } ) with infinite scrolling | Sort and filter by default |
ODataDataSource | OData v4 services (read-only); buildODataQuery for your own | Sort, filter, paging, counts |
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { OgeGrid, OgeColumn } from '@oge-ui/grid';
import { CustomDataSource } from '@oge-ui/core';
import type { LoadResult } from '@oge-ui/core';
interface Order {
id: number;
customer: string;
total: number;
}
@Component({
selector: 'demo-root',
imports: [OgeGrid, OgeColumn],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<oge-grid [data]="orders" keyField="id" [paging]="{ pageSize: 50 }" [filterRow]="true">
<oge-column field="id" caption="Order" dataType="number" />
<oge-column field="customer" caption="Customer" />
<oge-column field="total" caption="Total" dataType="number" />
</oge-grid>
`,
})
export class Demo {
// the server sorts, filters, groups and pages; the grid sends one query per change
// and aborts the previous request when a newer one starts
protected readonly orders = new CustomDataSource<Order>({
key: 'id',
load: async ({ signal, ...query }) => {
const response = await fetch('/api/orders/query', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(query), // { skip, take, sort, filter, searchText, … }
signal,
});
return (await response.json()) as LoadResult<Order>;
},
});
} Block loading while scrolling: scrolling.mode: 'infinite' (or 'virtual' with remote: true) fetches row blocks from the source as the viewport moves — rows only, no grouping or master-detail in that mode.
First render and loadSync
DataSource.load() returns a promise. ArrayDataSource (and a plain array, which the grid wraps in one) also implements the optional loadSync(), so in-memory data is in the very first render — on the server too (the server-rendered HTML contains the first page of rows). Remote sources omit loadSync and load after mount; give the page a loading state rather than an empty grid.
Load heavy families later
Wrap a family the first screen does not show in @defer; Angular splits its code into a chunk that loads on the trigger you pick. The suite does this itself: the grid renders its form and popup editors inside @defer, so @oge-ui/forms is not in a grid-only bundle.
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { OgeScheduler } from '@oge-ui/scheduler';
@Component({
selector: 'demo-root',
imports: [OgeScheduler],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<!-- the scheduler's code loads when the block scrolls into view -->
@defer (on viewport) {
<oge-scheduler [dataSource]="appointments" [currentDate]="date" style="height: 560px" />
} @placeholder {
<div style="height: 560px">Calendar</div>
}
`,
})
export class Demo {
protected readonly date = new Date(2026, 9, 5);
protected readonly appointments = [
{ id: 1, text: 'Design review', startDate: new Date(2026, 9, 6, 9), endDate: new Date(2026, 9, 6, 10) },
];
} Exports are separate entry points, and their libraries are optional peers — exceljs and jspdf load with the export chunk on the first export, not with the component:
// exceljs loads with this chunk, on the first click — not with the grid
async exportOrders(grid: OgeGrid<Order>): Promise<void> {
const { exportGridToExcel } = await import('@oge-ui/grid/export-excel');
await exportGridToExcel(grid, { filename: 'orders.xlsx', scope: 'all' });
}Bundle size
Gzip size of a few entry points (the baseline CI holds every pull request to, +10 %). Each number is the entry and its package-internal chunks; shared dependencies (@oge-ui/core, @oge-ui/behavior) are counted once, on their own row, and tree-shaking takes off whatever your app does not import. Every entry point is listed on Bundle size.
| Entry point | Gzip |
|---|---|
oge-ui | 2.5 kB |
@oge-ui/core | 55.5 kB |
@oge-ui/grid | 108.0 kB |
@oge-ui/grid/export-excel | 0.7 kB |
@oge-ui/inputs/select-box | 13.5 kB |
@oge-ui/buttons | 19.9 kB |
@oge-ui/buttons/fab | 10.5 kB |
Import per component where a family offers it — the @oge-ui/inputs barrel itself is tiny, but a per-editor entry keeps one editor from pulling in its neighbours' dependencies:
// one editor, not the whole inputs family
import { OgeSelectBox } from '@oge-ui/inputs/select-box';
import { OgeSplitter } from '@oge-ui/layout/splitter';
import { OgeFab } from '@oge-ui/buttons/fab';