// drop in via CDN
<script src="https://cdn.jsdelivr.net/npm/boostgrid@2"></script>
<link rel="stylesheet"
href="…/boostgrid@2/dist/boostgrid.css">
A grid worth shipping.
Sixty seconds, three files, zero dependencies.
Boostgrid is a modern data grid for Bootstrap 5. Written in TypeScript, no jQuery, polished for the modern browser. It renders, sorts, paginates and selects ten thousand rows in a hundred milliseconds, and the entire bundle weighs less than a single SVG icon.
- 5.51kb brotli · minified · standalone
- 10k/ 105ms first paint, ten thousand rows
- 0deps no jQuery, no runtime imports
- 17methods stable, typed public API
Install
three ways in$ npm install boostgrid
import { attach } from "boostgrid";
import "boostgrid/style.css";
attach("#grid");
// .NET / Visual Studio
PM> Install-Package Boostgrid
// drops boostgrid.min.{js,css}
// into ~/Scripts and ~/Content
Minimal markup
html + data-attributes
Mark up a regular Bootstrap 5 table, then either set
data-toggle="boostgrid" for auto-init or call attach().
<table id="grid" class="table table-hover" data-toggle="boostgrid"
data-selection="true" data-multi-select="true">
<thead>
<tr>
<th data-column-id="id" data-identifier="true" data-type="numeric">ID</th>
<th data-column-id="sender" data-order="asc">Sender</th>
<th data-column-id="received">Received</th>
</tr>
</thead>
<tbody>…</tbody>
</table>
Examples
live, copy-pastablePick an example on the left to see it run with the real bundle.
The full specification.
Options
passed tonew Boostgrid(t, …)
| Option | Type | Default | Description |
|---|
Methods
on a Boostgrid instance| Method | Returns | Description |
|---|
Events
DOM events + typedgrid.on()
| Event | Payload | Description |
|---|
Performance
why it is fast-
01
Single delegated listener
One click handler per grid; row buttons route through
data-bg-action—no per-row bindings to rebuild on render. -
02
Memoized derived view
Filter, sort and pagination are independent layers. Changing pages doesn't re-sort; sorting doesn't re-filter.
-
03
O(1) selection lookups
A
Map<id, Row>is built once on data load—select(),remove()andgetare constant-time. -
04
Debounced search
The search input coalesces keystrokes (200 ms by default) so the filter pipeline doesn't run on every letter.
-
05
CSS containment
The grid root carries
contain: content; rows usecontain: layout style—paint and layout stay inside the grid subtree. -
06
Bundle guarded at
< 15 KBEvery PR runs
size-limit. We ship at 5.51 KB brotli today—numbers tracked against the build, not estimated.
Every line that shipped.
A running log of every release. Versions follow semver: major bumps are breaking, minor bumps add features, patches are bug fixes only.
Polish trio. Three small, independent perf wins built on
2.4.2: the column-visibility menu now flips
hidden on existing cells in place;
the cell-paint branch is hoisted out of the inner cell
loop into a per-render closure array; and the virtual
scroll skips body rebuild when only the pad heights
changed. No public API surface change. Bundle ticks
to 15.68 KB brotli, still well under the 18 KB ceiling.
PERFDiffed column-visibility toggle
- Hide / show flips
hiddenin place. Toggling a non-frozen column from the visibility menu used to firerenderHeader() + renderBody() + renderFooter()— three full DOM rebuilds. Now onequerySelectorAllper toggle, then a boolean attribute flip on each matching<th>/<td>. Wins scale with row count. - Frozen columns still take the full path. Hiding a frozen column shifts its siblings' sticky offsets, which need the cached offset arrays to recompute. Most users don't hide frozen columns.
- Caveat (intentional). Colspan'd rows (group headers, "no results", master/detail panels) keep their previous colspan until the next full render. The visual overshoot is one column wide and harmless.
PERFPre-resolved cell-paint pipeline
- Branch hoisted out of the cell loop. The inner per-cell
col.formatter ? td.innerHTML = … : td.textContent = …branch fires N rows × M cols times per render. Now apaint[]array of closures is resolved once per column at the top ofrenderBody; the cell loop just callspaint[i](td, row). - Closures capture the column reference. Tested specifically (the bug-prone case is closing over the loop index) so formatter columns keep their per-row data correct.
- Tree-mode caret cells stay on the legacy composition path — their caret + label structure doesn't fit the simple paint signature.
PERFVirtual scroll: pad-only fast path
- New rows + same window = pad mutation only. When ajax delivers more rows but the user is scrolled at the top so the visible slice didn't move, only the pad
<tr>s'style.heightis updated. The data<tr>s keep their identity — preserving any active cell-selection rectangle and any focus the user had inside the body. - New private snapshot field.
grid.lastRenderedVirtualWindowholds the previous frame'sVirtualWindowso the next render can compare and decide whether to rebuild. Cleared ondestroy()and on virtual-scroll teardown.
MIGRATIONComing from 2.4.2?
- Drop-in. No public API additions, no behavior change for any documented call.
- Bundle: 15.46 KB → 15.68 KB brotli (+~220 B for the three diff branches and the snapshot field). Hard ceiling stays at 18 KB.
- Tests: 140 → 144 specs (+4 covering the new code paths).
Performance follow-up to 2.4.1. Single-row selection
toggles only touch the affected <tr>;
column-resize drag is rAF-throttled with a cached cell
list; new opt-in performanceMarks emits
User Timing entries so you can profile renders in
Chrome DevTools. Drop-in patch — no public API
surface change beyond the new option.
PERFDiffed selection toggle
- Single-row select / deselect updates only the affected row. Calling
grid.select([id])or clicking a row checkbox used to walk every visible<tr>viarefreshSelectionVisuals, even though only one row's state changed.select(rowIds)/deselect(rowIds)now route through a newrefreshSelectionForIds()fast path that mutates exactly the changed rows. Bigger wins on bigger pages. - Select-all / deselect-all keep the full-table path. When the caller passes no
rowIds, we fall through to the existing whole-table refresh — every row legitimately changed. - The header
select-allcheckbox is re-evaluated every call (cheapevery()overcurrentRows) so it stays in sync.
PERFColumn-resize live sync
- Pre-resolved matching
<td>list. The live width sync used to callquerySelectorAll("tbody > tr > td")and iterate it on every pointermove. Rows can't change mid-drag, so we now resolve the matching cells once at drag start and reuse them. - rAF-throttled. High-frame-rate pointers fire
mousemove120-240 Hz; we only need one width write per animation frame. The latest cursor X wins; intermediates are coalesced. - Synchronous flush on mouseup. If
mouseuparrives before the next rAF tick, we apply the pending move sync so the final committed width is exact.
FEATUREPerformance marks (opt-in)
- New option:
performanceMarks: true. When on, everyrenderHeader/renderBody/renderFooterpass emitsperformance.mark()+performance.measure()entries via the User Timing API. Mark name namespaced asboostgrid:<table-id>:<phase>. - Profile in production. Open Chrome DevTools → Performance → Record → interact with the grid → the entries surface under the Timings track. Lets app authors find slow formatters or measure the impact of their own customizations.
- Zero overhead by default. Off unless explicitly enabled — even
performance.markcalls add up at 60 Hz scroll, so we don't pay for what most users don't profile.
MIGRATIONComing from 2.4.1?
- Drop-in. No behavior changes for code that doesn't use
performanceMarks. - Selection events unchanged.
onSelected/onDeselectedstill fire with the same row arrays in the same order. - Bundle: 15.09 KB → 15.46 KB brotli (+~370 B). Hard ceiling stays at 18 KB.
Stability + performance patch. Out-of-order ajax responses can no longer overwrite fresher ones; the cell-edit listener leak is closed; render hot paths are cached for a 15–30 % speed-up on large datasets; state writes are debounced. No public API changes — drop-in replacement for 2.4.0.
FIXStability
- AJAX request sequencing. Rapid search typing or sort clicks used to fire overlapping fetches; if the later request resolved first the older response would silently overwrite it. A monotonic
ajaxRequestId+AbortControllerdrops stale responses and frees the network slot.destroy()aborts in-flight requests too. - Cell-edit listener cleanup. The inline
<input>'skeydown+blurhandlers were never removed when the editor closed. Setting the cell'sinnerHTMLdispatched a synthetic blur on the now-detached input which re-enteredcommit(). Fixed via explicitdetach()before swapping the input out.
PERFRender hot paths
- Frozen-offset arrays cached per render. The legacy
frozenLeftPx/frozenRightPxwalked the visible-columns array per cell, per row — quadratic in column count. Replaced bycomputeFrozenOffsets(), a single O(n) pass at render entry. 50 cols × 100 rows = 5000 walks → 1. - Selection refresh via row-index Map.
refreshSelectionVisualsusedcurrentRows.find()per visible row; now reads from the existingrowIndex: Map<id, Row>in O(1). - Search regex compiled once per phrase. Was rebuilt inside
applyFilteron every call; cached ongrid.searchRegexand invalidated whenever the phrase changes. - Sort comparator cached.
applySortrebuiltObject.entries(sortDictionary)+ a closure each call; now compiled once and reused until the sort dictionary changes. - Visible-cols + columnById Maps memoized per render. Eliminates repeated
.filter()and.find()walks in the body / group / footer / column-visibility paths. - Tree
markUpdedup. Walking ancestors per matched leaf used to re-walk the chain N times for N matching leaves; now short-circuits once an ancestor is in the seen-set. - Virtual scroll: skip render when the slice is unchanged. Small scrolls within the overscan window no longer re-render the body. The
requestAnimationFramecoalescer remains. - Virtual length read direct. The scroll handler used to call
getFilteredRows().length, which sliced the entire filtered array every event. Now reads the internallengthdirectly.
PERFState persistence debounced
- 200 ms trailing-edge debounce. A column-resize drag at 60 Hz no longer pays
JSON.stringifyper pointermove — writes coalesce into a single localStorage hit at the end of the burst. - New
grid.flushState()helper. Synchronously writes any pending save. Useful inbeforeunloadhandlers and tests that readlocalStoragemid-flight.destroy()flushes automatically.
TESTCoverage
- +7 specs in
test/quality.test.tscovering: out-of-order ajax → newest wins, destroy-during-fetch is silent, stale-input keydown is a no-op, debounce coalescing,destroy()flush, invalid BCP-47 locale doesn't crash, doubledestroy()is a no-op. - Total: 151 specs across 4 packages (was 144).
MIGRATIONComing from 2.4.0?
- Drop-in. No public API changes; all options, methods, and events keep their signatures.
- One additive method:
grid.flushState()for code that needs synchronous state writes. Optional. - State save is now async. Reads of
localStorageimmediately after a mutation may not see the latest payload until ~200 ms later. Callgrid.flushState()first if you need to read it sync (rare outside tests). - Bundle ceiling raised 15 KB → 18 KB brotli to make room for the AJAX-abort plumbing + frozen-offset arrays. Current size: 15.09 KB. The hard ceiling is enforced by
size-limit.
i18n & polish. Every UI string is now in
options.labels; pagination summaries
digit-group via Intl.NumberFormat; opt-in
sticky <thead>; auto-tooltips on
clipped cells. Bundle unchanged at 14.46 KB.
FEATURERound 7 — i18n hardening
- 14 new label keys covering every UI string introduced in 2.0–2.3 — the column-visibility panel, master/detail chevrons, bulk-action bar count + clear button, tree carets, drag-resize affordances. The legacy keys (
all,infos,loading,noResults,refresh,search) are unchanged. - Locale-aware digit grouping. Pass
locale: "de-DE"(or any BCP-47 tag) and the “Showing N entries” line usesIntl.NumberFormatfor thousands separators. Cached per locale so we don't allocate a formatter per render. - Override any subset. Missing keys fall back to English, so a partial translation works fine while you complete it.
FEATURERound 7 — Polish
- Sticky header.
stickyHeader: truepins<thead>viaposition: sticky. Coexists with frozen columns — z-indexes are arranged so the corner cell wins. Override the offset for fixed-nav layouts via--boostgrid-sticky-top: 56px. - Truncated-cell tooltips. A delegated
mouseoverlistener lazily attaches atitleattribute when a cell'sscrollWidth > clientWidth. Skips cells that already have a title or carrydata-bg-no-tooltip. On by default;truncatedTooltips: falseopts out.
MIGRATIONComing from 2.3?
- No breaking changes. The new label keys ship with English defaults; existing translations keep working.
- Net-zero bundle impact — the i18n routing replaced inline string literals that were already in the build.
stickyHeaderis opt-in to avoid surprising layouts on grids embedded inside small overflow containers.
Power-user UX & server-side. Master/detail row panels, spreadsheet-style cell selection with TSV copy, a sticky bulk-action bar, animated loading skeleton, and an ajax payload that surfaces grouping + tree state for server-driven grids.
FEATURERound 6 — Power-user UX
- Master/detail row expansion.
rowDetail: (row) => string | HTMLElementrenders an expand chevron in a leading affordance cell. The colspan'd panel below the row hosts whatever you return — returnnullto skip a row's panel. - Cell selection & clipboard.
cellSelection: trueturns the body into a spreadsheet — click anchors, shift-click or drag extends the rectangle, Ctrl/Cmd+C copies as tab-separated values, Esc clears. - Bulk-action bar.
bulkActionsrenders a sticky toolbar that slides in whenever selection is non-empty, with an auto-prepended count and "Clear" button. - Loading skeleton. Animated placeholder rows replace the blank body during ajax fetches by default. Numeric override or
loadingSkeleton: falsefor the previous behaviour.
FEATURERound 6 — Server-side adapters
- AjaxRequest extensions.
groupBy+collapsedGroupsare sent on every fetch when grouping is on;treeMode+expandedTreeNodeswhen the tree is on. Single-stringgroupByis normalized to an array. Existing endpoints see the same payload they always did — new fields appear only when their feature is active. - Adapter sketch. Worked Express example demonstrates how to switch query shape based on the request payload: paginated flat, group-sorted, or root-plus-expanded-children for tree.
MIGRATIONComing from 2.2?
- No breaking changes.
cellSelection,rowDetail,bulkActionsare all opt-in. The ajax extensions only appear in the request payload when their feature is on. loadingSkeletondefaults totrue— set it tofalseif you have a custom in-flight UI you'd rather keep.cellSelectionconflicts withrowSelect(the whole-row click handler). Pick one.
Column UX & tree polish. Drag-reorder + drag-resize on every column, frozen-right pinning, a richer column-visibility panel, drag-to-reparent on tree rows, and tree-aware export indentation.
FEATURERound 5 — Column UX
- Frozen-right columns.
frozen: "right"pins a column to the trailing edge during horizontal scroll. The shadow now reads from both directions when frozen prefixes and suffixes are present. - Column reorder. Drag any header to rearrange. Frozen-group constrained — dropping a left-frozen column onto a non-frozen target snaps to the end of the frozen run. Persisted in v:3 state.
- Column resize. Drag the right edge of a header to resize. Live-updates body cells without a full re-render.
minWidth/maxWidthper column; persisted ascolumnWidths. - Visibility panel polish. Search input, drag-handles for reorder, "Reset to defaults" button. The panel is also a reorder UI — pulling the same
reorderColumn()backend.
FEATURERound 5 — Hierarchical polish
- Tree drag-to-reparent. Opt-in via
treeReparent: true. Drop a row onto another to changeparentId. Cycle-guarded;onReparentcan return a Promise that resolvesfalseto abort. - Subtotal-on-top option.
groupSubtotalsOnTop: trueemits group footers above their member rows, applied at every grouping level uniformly. - Tree-aware export. The export plugin's new
treeExportoption indents the tree-column cell by depth, or inserts a leadingPathcolumn with slash-joined ancestors. Zero core cost.
MIGRATIONComing from 2.1?
- No breaking changes. Reorder + resize default to on — opt out via
columnReorder: falseorcolumnResize: false, or per-column withreorderable: false/resizable: false. - State schema bumped from v:2 to v:3 to carry
columnOrderandcolumnWidths. v:2 payloads still apply non-column fields cleanly. treeReparentstays off by default — it mutates user data, so it asks for explicit consent.
Hierarchical data, end to end. Multi-level grouping closes the depth gap left in 2.0; tree mode ships adjacency-list parent/child rows with ancestor-aware search.
FEATURERound 4 — Hierarchical data
- Multi-level grouping.
groupBynow acceptsstring | string[]; nested headers indent per depth, andgroupAggregatorscan target a specific tier with thecolId@Nkey syntax. - Tree data mode. Set
treeMode: truewith aparentIdfield on each row — Boostgrid reconstructs the tree, renders DFS with depth-driven indentation, and exposestoggleTreeNode/expandAllTree/collapseAllTree. - Ancestor-aware search. When the search input matches a deeply-nested leaf, its entire ancestor chain stays visible so the path to the hit is never hidden behind a collapsed parent.
- State persistence v:2. Schema bumped from v:1; collapsed group paths and expanded tree nodes round-trip via
localStorage. v:1 payloads still apply non-hierarchical fields cleanly. - Cycle & orphan defense. Tree builder warns and breaks cycles, promotes orphan rows to roots — never crashes on malformed input.
MIGRATIONComing from 2.0?
- No breaking changes. Existing
groupBy: "status"code keeps working — the array form is purely additive. - If you persisted state in 2.0, that v:1 payload still applies; new collapse/expand state starts fresh under v:2.
- The
GroupContextshape gainedgroupPath+depth; existing aggregators that read onlyrows/groupKey/groupLabelneed no change.
A complete vanilla TypeScript rewrite. Zero runtime dependencies, ESM + UMD, ~9 KB brotli, three framework wrappers and an export plugin in lockstep.
FEATURERound 3 — Power features
- Virtual scroll. Opt-in via
virtualScroll: true. Windowed rendering with rAF coalescing — sustained ten thousand rows at sixty frames. - Cell-level edit. Per-column
editablewithtext / number / selecteditors,onCellEditcommit hook, andrevert()for async-server rollback. - Row grouping with subtotals.
groupBy+groupAggregatorsrender group headers, member rows, and per-group footers. Collapse state persists. - Frozen left columns. Sticky positioning with cumulative left offsets and a scroll-shadow that fades in only after
scrollLeft > 0. boostgrid-exportplugin. CSV (RFC 4180), Excel via lazyxlsx-js-style, and Print. Zero bytes added to the core bundle.vue-boostgridwrapper. Vue 3 +<script setup>; same imperative handle as the React wrapper.
FEATURERound 2 — Type safety & state
- Generic over
TRow.Boostgrid<TRow>threads your row shape through every formatter, callback, footer and edit hook. Full inference end-to-end. - State persistence.
stateSave: true— sort, search, page, rows-per-page, selection, column visibility and collapsed groups round-trip via localStorage with a versioned schema. - Footer aggregation. DataTables-style
footer: truewith per-columnfooterFormatteror whole-rowfooterCallback.FooterContextexposes filtered + current-page row sets.
BREAKINGRound 1 — The rewrite
- jQuery removed. The library is now vanilla TypeScript. The
$.fn.bootgrid()entry point is gone; useattach()ordata-toggle="boostgrid". - Modern packaging. ESM + UMD output, side-effect-free, tree-shakeable formatters.
exportsmap for first-class subpath imports. - Bootstrap 5 native. Pagination, toolbar and badges use BS5 classes and CSS variables. No bespoke theming layer.
- React wrapper.
react-boostgridships in the same monorepo. Host-div pattern keeps the reconciler off the table DOM. - Single delegated listener. One click handler per grid; row buttons route through
data-bg-action. - Bundle ceiling. 15 KB brotli enforced by
size-limit. Currently ~9 KB.
MIGRATIONComing from 1.x?
- Replace
$("#grid").bootgrid({…})withattach("#grid", {…}). - Column option names are kebab-case in markup (
data-column-id,data-identifier) and camelCase in JavaScript (columnId,identifier) — same as before. - Custom formatters are now plain functions
(value, row) => string; nothisbinding needed. - The
loaded,selected,deselectedevents still fire as DOM events; you can also usegrid.on("selected", …).
The original was jquery-bootgrid: a Bootstrap 3 / 4 table plugin in jQuery. It served well for nearly a decade. Boostgrid 2 is the spiritual successor — same data-attribute ergonomics, modern internals.
The 1.x line is no longer maintained. Source remains available on the legacy GitHub repository for archival reference. New projects should start on 2.x.
Items still ahead — listed here so adopters can see where the road goes, not as promises. Frozen-right, column reorder + drag-resize, and tree drag-to-reparent all shipped in 2.2.
- Variable-row-height virtual scroll (measure-on-mount).
- PDF export.
- Accessibility: full ARIA grid pattern + keyboard navigation.
- i18n — extracted strings, locale-aware formatters.
- Web-Worker sort/filter for very large in-memory datasets.
Colophon
about the maintainerA letter from the maintainer
Hi, my name is Jeffery Leo.
I built Boostgrid—a modern, Bootstrap 5-native data grid for the modern browser. By day I work on developer tooling, .NET back-ends, and modern web front-ends.
Boostgrid is a clean, typed, vanilla, tree-shakeable package that drops into any framework. No jQuery, no runtime dependencies, less than 15 KB gzipped, and a public API that stays out of your way.
The project is permissively licensed, public on GitHub, and accepts contributions of every shape. If something irks you, open an issue and tell me about it; I read every one.
— Jeffery written in visual studio · 2026