Live Demo
Eight small grids, each isolating one thing: raw scroll performance at 1,000,000 rows, inline editing, filtering, frozen columns, pinned rows, row selection, column types/renderers, and theming. Every grid on this page is a real JHGrid instance, loaded on the published site from the jsDelivr CDN build; view source on this page to see the exact code. The snippets under each heading are the options that section is about — the page’s own script has the whole thing.
1,000,000 rows
Scrolling stays smooth because only the visible cells are ever drawn; the other 999,900-odd rows don’t exist in the DOM. Drag the scrollbar to jump around; nothing gets slower the further down you go.
Booting…
Nothing about the grid changes at this row count — it asks for one page of rows at a time and throws away the pages it has scrolled past:
new JHGrid({
container: '#grid',
columnDefs: [{ field: 'id', label: 'ID', width: 60, align: 'right' }, /* … */],
fetchMeta: async () => ({ totalRows: 1_000_000, columns: ['id', 'name', 'email'] }),
fetchData: async (page, size) => ({ rows: ROWS.slice(page * size, page * size + size) }),
});
Point fetchData at your API instead of an array and the row count stops mattering at all — see fetchMeta and fetchData.
Editing
Double-click a cell to edit it. Ctrl+Z / Ctrl+Y undo and redo; Ctrl+C / Ctrl+V copy and paste a range.
editableCols decides what can be edited — '*' for every column, or a list of fields. type picks the editor, and validation rejects a bad value with a red border and a message:
new JHGrid({
container: '#grid',
data: rows,
editableCols: '*',
columnDefs: [
{ field: 'name', label: 'Name', width: 150, validation: { required: true, minLength: 2 } },
{ field: 'dept', label: 'Dept', width: 140, type: 'dropdown', options: DEPTS },
{ field: 'active', label: 'Active', width: 90, type: 'checkbox' },
],
});
See Validation and Editor Options.
Filtering
Type in the box for a quick cross-column filter, or click the small ▾ indicator at the right edge of a column header (it turns amber when that column has a filter applied) for a per-column filter panel.
The per-column panel needs no wiring; the box above is one call against your own input:
const grid = new JHGrid({ container: '#grid', data: rows, columnDefs });
document.querySelector('#quick-filter').addEventListener('input', (e) => {
grid.setQuickFilter(e.target.value);
});
See Set Filter / Quick Filter and setFilter / setSort for filtering from code.
Frozen columns
frozenCols / frozenColsRight pin columns to either edge. Scroll right: Name and Dept stay put on the left, Score stays put on the right.
Both are counts, not field names: the first frozenCols columns and the last frozenColsRight columns of columnDefs are the ones that stay put.
new JHGrid({
container: '#grid',
data: rows,
frozenCols: 2, // Name, Dept stay at the left edge
frozenColsRight: 1, // Score stays at the right edge
columnDefs: [
{ field: 'name', label: 'Name', width: 130 },
{ field: 'dept', label: 'Dept', width: 130 },
/* … */
{ field: 'score', label: 'Score', width: 90, align: 'right' },
],
});
Users can also freeze a column from its header menu — see Frozen Columns.
Pinned rows
pinnedBottomRows keeps a row fixed at the bottom of the grid, detached from scroll/sort/filter — the classic use case is a totals row. Scroll or click a column header to sort; the total row never moves and is never affected. rowPinButton draws a pin at the right edge of the row-number gutter — hover a row to see it, click to pin a copy to the top. The button below does the same thing from code with pinRow(), for when the pin should be triggered some other way. Either way it becomes its own independent copy, so editing it afterward doesn’t touch the original row.
A pinned row is a plain object, so a totals row is whatever you compute from the data you already have — the grid never sums anything on its own:
const grid = new JHGrid({
container: '#grid',
data: rows,
rowPinButton: true, // the pin in the row-number gutter
columnDefs,
pinnedBottomRows: [{
name: `Total (${rows.length})`,
salary: rows.reduce((sum, r) => sum + r.salary, 0),
}],
});
// Same thing from code, on a row the user has selected:
const [row] = grid.getSelectedRows();
if (row != null) grid.pinRow(row, 'top');
See Pinned Rows.
Row selection
rowSelection: 'multi' selects rows by clicking the row-number gutter on the left (Ctrl/Shift to extend a selection, the same as a spreadsheet). onRowSelect(rows) fires with the selected row indices on every change; getSelectedRows() reads them back at any time.
0 selected
const grid = new JHGrid({
container: '#grid',
data: rows,
showRowNumbers: true, // the gutter the click lands in
rowSelection: 'multi', // or 'single', or 'none' (the default)
columnDefs,
onRowSelect: (rows) => {
document.querySelector('#count').textContent = `${rows.length} selected`;
},
});
grid.getSelectedRows(); // [3, 4, 7] - row indices, in ascending order
For the other pattern — a checkbox column with a tick-everything box in its header — see Row Selection / Header Checkbox.
Column types & renderers
columnDefs[i].type picks a built-in editor/renderer pair (dropdown, checkbox, date, …); renderer swaps in a different CellRenderers entry without changing the editor, e.g. formatting a plain number as currency.
// The Rating column's renderer: args.text() writes one line into the cell box, using the
// column's own alignment, so a renderer like this needs no coordinates.
registerCellRenderer('stars', () => (ctx, { value, text }) => {
const n = Math.max(0, Math.min(5, Math.round(Number(value) || 0)));
text('★'.repeat(n) + '☆'.repeat(5 - n), { color: '#f59e0b', size: 15 });
});
new JHGrid({
container: '#grid',
data: rows,
editableCols: '*',
columnDefs: [
{ field: 'dept', label: 'Dept', width: 150, type: 'dropdown', options: DEPTS },
{ field: 'active', label: 'Active', width: 90, type: 'checkbox' },
{ field: 'joined', label: 'Joined', width: 130, type: 'date', format: 'YYYY-MM-DD' },
{ field: 'salary', label: 'Salary', width: 140, align: 'right',
renderer: CellRenderers.currency({ locale: 'en-US', currency: 'USD' }) },
{ field: 'rating', label: 'Rating', width: 110, align: 'right', renderer: 'stars' },
],
});
The Joined column names its own format. Leave format out and the column follows the locale’s date pattern instead, so the same stored 2026-03-14 reads 03/14/2026 under en and 2026/03/14 under ja — see Date Columns.
Thirteen renderers ship built in (progressBar, badge, checkmark, link, image, …) and registerCellRenderer() adds your own — see Built-in Cell Renderers and Column Types.
Themes
setTheme(patch) repaints a live grid in new colours: the select below hands the grid the dark token set and hands back the defaults for those same tokens, with no rebuild - unsaved edits, the selection and the scroll position all stay where they were. See Changing the Theme at Runtime and Themes for the full token reference.
theme is a partial override: name only the tokens you want to change and the rest keep their defaults.
const DARK = {
headerBg: '#111827', headerText: '#e5e7eb', headerBorder: '#374151',
rowEven: '#1f2937', rowOdd: '#111827', cellText: '#f9fafb', cellBorder: '#374151',
selectionColor: '#60a5fa', selectionFill: 'rgba(96,165,250,0.18)',
selRowBg: 'rgba(96,165,250,0.14)', scrollbarBg: '#111827', scrollbarThumb: '#4b5563',
frozenBorder: '#4b5563',
// The default hover wash is a black tint - invisible over dark rows.
hoverRowBg: 'rgba(255,255,255,0.055)',
};
let grid = null;
function build(dark) {
grid?.destroy();
grid = new JHGrid({
container: '#grid',
data: rows,
editableCols: '*', // otherwise every column gets the light readonlyCellBg tint
theme: dark ? DARK : undefined,
columnDefs,
});
}
build(false);
All 58 tokens are listed in Themes, which also covers the CSS variables the non-canvas parts (menus, panels, the pager) read.