grid-minimal-js — Vanilla Grid Minimal (1k–1M client-generated rows)
The most stripped-down demo: no backend, no DataManager HTTP calls, no
localization. A single <vn-grid> fed client-generated rows through
StaticDataManager — 1,000 by default, selectable up to 1,000,000 from the
toolbar — to exercise the grid's virtualization, sorting, filtering,
multiple-row selection, and settings persistence under an in-memory dataset of
any size.
What it shows
Resizable static dataset —
app.jsgenerates rows with a deterministic pseudo-random seed (so reloads look consistent) and hands them towindow.StaticDataManager. The size is chosen at runtime from the toolbar's row-count dropdown (see below), which is what makes the size-dependent behaviour — shimmer thresholds, search-index pre-warming, filter narrowing — observable without editing a URL. Theme changes callrefresh()— notreload()— so switching themes rebinds the visible rows and resets scroll without re-fetching the dataset from the DataManager.In-toolbar row-count selector —
<grid-minimal-rows-selector>(rows-selector.js) offers 1,000 / 5,000 / 10,000 / 50,000 / 100,000 / 500,000 / 1,000,000 rows, starting at 1,000 on a first visit and at the last size picked on any later one (see App preferences below). Picking a size regenerates the dataset and re-ingests it through the existing manager's ownsetRows()— oneStaticDataManagerlives for the page's lifetime, sosearchFields,internStringColumnsandprewarmSearchIndexkeep applying to every new dataset. The switch runs release-then-generate behind the loading overlay: the manager is emptied (plusdestroy(), which terminates the filter Worker and hands back the second copy of the searchable text it holds in its own thread) and the grid reloaded to zero rows before the new rows are generated, so the outgoing and incoming datasets are never both resident — the worst case, 500,000 → 1,000,000, peaks at roughly the new dataset alone. The overlay is opaque and click-blocking for that whole window, so the deliberately-empty grid in the middle of it is never visible. A?rows=query parameter overrides the starting size and takes precedence over the dropdown, which then renders nothing at all — that is the mode the Playwright suite runs in.Scoped free-text search — the
StaticDataManageris constructed withsearchFields: ['id', 'firstName', 'lastName', 'email', 'country', 'city', 'department', 'role', 'manager', 'notes'], so the search index covers those ten columns rather than all 21 (the numeric, date and boolean columns are omitted — nobody free-text-searches them). Without it the manager haystacks every field of every row — a full second lowercased copy of the dataset's textual content, and an index build that scales with the field count — to match against values nobody free-text-searches (a bonus amount, an epoch timestamp). At this dataset's scale that is roughly 2x the build time and resident memory for no user-visible benefit, which is why the manager warns about the unscoped case on large datasets. Column filters are unaffected: all 21 columns stay independently filterable and sortable.Search-match highlighting —
#demoGridsetshighlight-search-matches, so every visible cell whose formatted text contains the active search term gets that substring wrapped in<mark>. Theidcolumn opts out (highlightSearchMatches: false) since a numeric ID coincidentally containing the search digits as a substring (e.g. searching"12"matching id1234) is noise, not a meaningful match — a real host with a similar numeric/ID column should copy this pattern. See docs/vanilla-grid/02-row-virtualization-and-custom-scroll-implementation.md §1.17.String interning — the
StaticDataManageris constructed withinternStringColumns: ['manager'], deduplicating that column's values so equal names share one string instance (~16 MB off a 500k-row run). Onlymanagerqualifies, and the reason is worth reading before copying the option elsewhere: interning pays off only for a column that is low-cardinality and holds a distinct string instance per row.manageris built by concatenating two picked names, so every cell is a separate allocation despite only ~572 distinct values. The other low-cardinality columns (country,city,department,role,firstName,lastName,active) are assigned straight out of this app's literal source arrays, so every row already points at the same few shared instances and interning them reclaims exactly nothing;emailandnotesembed the row number and are effectively unique, so they dedup to nothing either. This demo is therefore the atypical case — a host whose rows arrive fromJSON.parse()allocates a fresh string per cell, which inverts the picture and makes the low-cardinality columns the dominant win (~40% of retained heap). See the data-manager docs §5.3 for the measured comparison.Search-index pre-warming — the
StaticDataManageris constructed withprewarmSearchIndex: true, so it warms its own index at idle once the rows are ingested: the one-time haystack extraction happens after first paint rather than on the user's first keystroke, making the first search instant without ever competing with the initial render. No host wiring — the option replaced an equivalentvn-grid-loaded+requestIdleCallbacklistener, and the manager re-schedules the warm on everysetRows(), so a row-count change re-warms the new dataset on the same terms. Off by default; it earns its keep at the larger row counts, not on the small?rows=runs the test suite uses.Dev-mode diagnostics —
app.jssetswindow.VanillaGridDevMode = true, so the console shows when the search index starts building and how long it took (VanillaGrid: search index build started …/… built in N ms …, naming the row count, searched fields and worker/in-thread path). That is how you verify the pre-warm above really starts early and what indexing this dataset actually costs. Opt-in and off by default — set here because this frontend is a development/demo harness, not something a production host would enable.Non-blocking, all-in-memory generation — rows are built in 50,000-row chunks, yielding to the event loop between each so the loading overlay keeps painting a live
Preparing N rows… %readout and the tab stays responsive at high row counts, instead of freezing the main thread on one long task. The generator is re-runnable (the row-count selector calls it again for every new size) and re-seeds itself per call, so row N is always row N within a session. Date fields (birthDate,hireDate,lastLogin,updated) are stored as epoch-millisecond numbers rather thanDateobjects — the grid coerces them for display, sorting, filtering and export, and dropping the fourDateallocations per row roughly halves the dataset's heap footprint at large sizes. (Trade-off: free-text search over a date column matches the raw epoch digits, not a formatted date string.) The full dataset still lives in memory — this is deliberately the all-in-memory demo.21 mixed-type columns —
number,string,date,datetime, and abooleancolumn (booleanCoerce: 'loose','Yes'/'No'→ tri-state checkbox). Includes two customrenderCellcolumns: a star-rating column (clipped filled stars over empty stars), pinned at 130 px andresizable: falseso the five stars always fit on one line and carrying atitletooltip with the numeric value (3.4 / 5) since a clipped half-star can't be read off the glyphs, and a height gauge column — a horizontal 150–200 cm track with the typical adult range (160–185 cm) shaded, ticks at the band edges, and a value dot (teal inside the range, magenta outside). The gauge column keepstype: 'number', so sorting and filtering still run on the raw cm value, and is width-clamped to 200–300 px (minWidth: 200,maxWidth: 300) so the track stays readable without the gauge stretching.Whole-number currency — the
salaryandbonuscolumns carryformatOptions: { minimumFractionDigits: 0, maximumFractionDigits: 0 }, which the grid hands straight toIntl.NumberFormat, so the cells render rounded (50,585rather than50,585.26). Display only: the generated data still holds two decimals, so sorting, filtering and Excel export all work off the unrounded value.hoursdeliberately keeps its default fractional formatting alongside them, so the columns show the contrast.Multiple-row selection — the grid runs in
selection: { mode: 'multiple' }keyed off the uniqueidfield (rowKeyField: 'id', set inapp.js). The<vn-grid-toolbar-status>row-count line appends the "N selected" suffix, and the toolbar swaps to itsselectedtemplate while a selection exists. While a filter/sort/reload re-queries (grid busy, nothing loaded yet) the status item renders nothing — the component's default; the grid's shimmer carries the busy signal — so no transient, torn-down-state "Loaded 0" ever shows. Unlike the other plain-JS samples, this app pushes no host status message (nosetStatusMessage()call), so it mounts no<vn-grid-toolbar-status-message>.<vn-grid-toolbar>with inline theme and row-count selectors — the toolbar authors anemptyand aselectedtemplate. Theselectedtemplate adds a selection-scoped Excel export (client-sidexlsxblob, no backend) and a clear-selection button, using the toolbar's standardexportToExcelcommand as-is (no host override needed):exportToExcel()'sthemeStyleoption defaults to'auto', so the exported cells are styled to match the grid's active theme automatically (all 9 themes, including the frontend-local Apple one, declare the--vn-grid-export-*tokens the'auto'palette reads). The search box appears in both; the two selectors are a matched pair with identical visibility rules, so they always come and go together: both live only in theemptytemplate (the selection row stays focused on selection actions), share one 190 px.select-wrappershell instyles.cssso the two pills line up, and both grey their<select>out in place — they are never hidden (_applyBusyState()) for the whole busy window of a filter/search/sort/reload, reading the toolbar's ownstate.busyfield — so neither can be used to start a second switch while one is still in flight. Disabling rather than hiding is what keeps the toolbar's layout stable: on a large dataset that window is seconds rather than milliseconds (a theme switch reloads, and 1,000,000 rows are then re-ordered and re-projected behind the shimmer), and hiding pulled both 190 px controls out of the middle of the toolbar row for its entire duration, which read as a broken toolbar rather than a busy one. The toolbar's reset command is no longer an example of such a window — as ofvanilla-grid1.36.0 it skips the reload when the reset did not change the query, and orders the dataset once instead of repeatedly. Both templates lay out their toolbar row with three<vn-grid-toolbar-group align="start|center|end">zones (empty: status / theme+rows selectors / search+commands;selected: status+export / clear-selection) rather than a<vn-grid-toolbar-spacer grow>pair. The spacer pair used to centre the theme/rows selectors in the gap between the left and right blocks, not in the row: since the right block (search + four commands) is wider than the left (status alone), the selectors sat a constant ~50px left of true centre, at every viewport width, in every theme — a sample-markup defect, not a component one (the component's own docs never claimedspacer growcentres anything). Expanding the Carbon search used to slide them a further ~224px left, since a spacer pair reshares free space with whatever grew. With<vn-grid-toolbar-group>thestart/endzones are always equal width to each other, so thecenterzone genuinely sits at the row's midpoint and no longer moves when the search expands — seedocs/vanilla-grid-toolbar/01-usage-guide.md§4d. The grid's shimmer already carries the busy signal. Because the toolbar re-clones fresh instances each time it returns toempty— and a fresh<select>would reset to its first option — each re-syncs its dropdown on reconnect, so a selection round-trip doesn't snap either back to its first entry:theme-selector.jsreads the grid's current theme (_restoreSelectedTheme()), whilerows-selector.jshas no grid-side row-count property to read and instead reads the countapp.jspublishes aswindow.sf5CurrentRowCount(_restoreSelectedCount()). The one asymmetry is?rows=: when that parameter is present the row-count selector renders nothing at all, since the URL is asking for a size the preset list may not contain. Theselectedtemplate's export button carrieshide-when="busy"on top of its own built-in auto-disable (<vn-grid-toolbar-export>disables itself whilestate.busyis true regardless) — so a mid-reload click can't hand the exporter a torn-down row set, and the button disappears rather than merely greying out during that window. That last part is deliberately unlike the two selectors above: the export button sits at the row's left edge beside the status text, where vanishing costs nothing, while they sit mid-row. Standard commands (search, auto-fit, reset, clear-filters, reload) dispatch straight to the grid — no host wiring.Theme-aware app canvas — the page background follows the active grid theme's light/dark mode (
styles.css): a soft neutral canvas for the light themes, a dark canvas matching each theme's own surface for IBM Carbon Dark and Glow Dark, keyed off the grid's reflectedthemeattribute so it tracks live theme switches. The search box's presentation follows the active theme — the expandable (Carbon-style collapsing) variant under the IBM Carbon light/dark themes, the standard inline box otherwise — toggled live (this app swaps themes without a page reload) bytheme-selector.js's_switchTheme(). The command/export/clear buttons likewise follow the theme: the frontend-local Apple theme presents them as solidprimarybuttons (filled with the theme accent), while every other theme keeps the flatghostvariant authored in the markup — swapped live by the same_switchTheme()(applyToolbarCommandVariant()). The theme and row-count selectors are custom toolbar sub-components (<grid-minimal-theme-selector>,<grid-minimal-rows-selector>) that live in the toolbar row.9 selectable themes — 8 grid built-ins (default, Material, Fiori, Carbon, Carbon Dark, Glow, Glow Dark, Fluent) plus a frontend-local Apple theme registered at runtime on both components — the grid via
VanillaGridElement.registerTheme('apple', 'themes/vn-grid-apple.css')and the toolbar viaVanillaGridToolbarElement.registerTheme('apple', 'themes/vn-grid-toolbar-apple.css'), so the (theme-unset) toolbar resolves its own Apple stylesheet when it mirrors the grid'sappletheme. Being frontend-local does not make the Apple theme optional-grade: it consumes the same base stylesheet as the built-ins and so owes the same required token set (seesrc/vanilla-grid/themes/TEMPLATE-vn-grid-theme.css). It follows macOS conventions where they differ from the built-ins — only the active sort column is marked, with a thin chevron (⌃/⌄) and no affordance on unsorted columns at rest or on hover, and the filter glyph is SF Symbols'line.3.horizontal.decrease(three stacked rules) rather than the base funnel. Its row-grouping block follows the same principle: macOS-style capsule chips, and chip direction arrows using the same⌃/⌄at the same 20px the theme gives its column headers, so a chip and a sorted column state direction in one language (seedocs/vanilla-grid/15-themes-implementation.md§ 4.8 — grouping appearance is entirely theme-owned, with the base stylesheet holding only mechanics).tests/node/sort-icon-theme-tokens.test.jsscanssamples/*/themes/vn-grid-*.cssalongside the built-ins, so an incomplete local theme fails the suite instead of silently rendering no sort glyph.Declarative grouping and sorting — the grid opens grouped Country ▸ City with the rows sorted by Rating descending inside each city, and none of that is wired in JavaScript. Both are plain HTML attributes on
<vn-grid>inindex.html:<vn-grid id="demoGrid" theme="fiori" storage-mode="local" group-by="country:asc, city:asc" sort-by="rating:desc"></vn-grid>Entry order is meaning — nesting order (outermost level first) for
group-by, sort-chain order (primary column first) forsort-by— and the direction is required on every entry. The two compose without any coordination code: the group fields lead the effective sort, sorating:descbecomes the residual order within each city. Because an applied group level's column leaves the grid, Country and City are not in the header while the default grouping is active; the group bar's chips are where they live (drag a chip out, or use the header menu, to get them back). See Row Grouping §15 and Sorting §12.1.A
?group=nonequery parameter opens the grid flat instead, by removing both attributes beforeinitializeGrid()reads them (they are read once, at that call) and emptyinggrouping.aggregatesin the same breath. Same role as?rows=: a URL that pins the demo into a known starting state. The Playwright suite uses it wherever a spec is about the ungrouped grid — building a grouping from scratch, the group bar's "occupies no space until a group state exists" behaviour, or asserting a single column's own sort order.Fold a country's cities in one click — Shift-click a country caption, or press Shift+Enter on it, to show it as a list of folded city captions; Shift-click again to open every city. Built into the grid's caption toggle, so nothing in
app.jswires it.Group aggregates: Sum of Projects and Average of Height — a group footer row closes each group at each level, carrying the total of
projectsand the average ofheightin those columns' own cells, each with its function's marker icon (Σ,x̄) and the column's own number formatting — a plain number under Height's gauge, since a column'srenderCellnever runs for a footer cell, and with up to three decimals, since an average of whole centimetres is not a whole number. Hovering an aggregated cell names the function in full ("Average of Height: …"). They are declared inapp.jsrather than in the markup, becausegrouping.aggregateshas no<vn-grid>attribute of its own:gridEl.initializeGrid({ grouping: { aggregates: [ { key: 'projects', fn: 'sum' }, { key: 'height', fn: 'avg' }, ] } });Like
group-by, they are a default, not a lock: right-click any column header for "Aggregate ▸" to pick a different function or take one off, and that choice persists with the group state and wins on the next visit. Every column type in this sample offers something: the number columns every statistic (Sum, Average, Median, Minimum, Maximum, Distinct count, and the custom Standard deviation below), the date columns Minimum, Maximum and Distinct count, Active True count, and the string columns Distinct count and the custom Most common. Collapse every group (the group bar's Collapse all) and the grid becomes a summary table — a collapsed group still shows its own totals.Two things this sample demonstrates by simply existing. The statistics are offered on every
numbercolumn,idandratingincluded, because the type gate screens what is computable, never what is meaningful — that judgment is left to whoever builds the grid. And an aggregate belongs to the grouping session: ungroup entirely and the configuration goes with the levels it was reducing over. See Row Grouping §5.2.Custom aggregates: Standard deviation and Most common —
app.jsregisters two functions of its own withVanillaGrid.registerAggregate(), and they appear in the "Aggregate ▸" menu of every eligible column next to the built-ins:stdev(σ,numbercolumns) — the sample standard deviation, streamed with Welford's algorithm (the same reducer the component README shows). The generator draws every number uniformly, so σ is nearly the same in every group (Salary ≈ 41,860, Height ≈ 14.4). That's expected, not a bug.mode(Mo,stringcolumns) — the most frequent value in the group, e.g. the most common Department per City; ties go to the value that sorts first, so re-sorting never changes it. It's the only aggregate here that returns text. It keeps one entry per distinct value per group, so on a column of unique values (Email, Notes) at 1,000,000 rows it is as slow as Distinct count on the same column.
Both are registered before
initializeGrid(), so an aggregate a visitor picked on a previous visit resolves when the grid restores it. A reducer receives one column's values, never the row, so neither can combine two columns. A registered function has no icon: its name and its text marker come fromformatting.messages(aggregateFunctionLabels/aggregateFunctionMarkers), which is what putsσandMoin the footer cells and the header badge while the built-ins keep their icons.Settings persistence —
storage-mode="local"and nopersistenceoverride inapp.js, so every domain the grid enables by default is stored inlocalStorage: column widths, order, visibility and freezing, plus group state (levels and the configured aggregates, which share one key and one flag), sort state and the filter model. That makes the declaredgroup-by/sort-byabove a default, not a lock — a visitor who regroups, re-sorts or filters a column finds that same view on the next visit, and only a first visit (or cleared storage) opens on the markup defaults. The toolbar'sclearPersistedSettingscommand wipes what was stored and restores those defaults. A demo that had to open grouped, unfiltered and Rating-sorted no matter what would opt the individual domains back out instead (persistence: { groupState: { enabled: false } }, and likewise forsortState/filterModel). The search term is the one domain off by default component-wide, and this app does not opt in.App preferences — the selected theme and the selected row count are the sample's own state, not the grid's: the theme is a page-wide choice this demo also paints its canvas from, and the row count is a property of the dataset
app.jsgenerates, which the grid knows nothing about. Neither can ride on the component's settings persistence, soprefs.jsstores them under agrid-minimal-js:key prefix of its own and both selectors read it while building their dropdowns. The theme is restored during parsing, before<vn-grid>upgrades: the restore rewrites the element'sthemeattribute (and the toolbar templates' theme-dependentexpandable/variantattributes) so the stored theme is the first one the grid resolves, with no stylesheet fetched for the markup default and no theme flash. The row count is written only once a switch has actually landed, so a failed one never reopens the demo at a size it never reached, and?rows=overrides the stored size without overwriting it — that visit renders no dropdown, so nothing is picked.Loading overlay — visible by default (painted before row generation starts) with a spinner and a live
#loadingStatusprogress readout that the chunked generator updates per chunk; removed once data is loaded.
Script load order (why prefs.js, theme-selector.js and rows-selector.js load first)
theme-selector.js and rows-selector.js are plain (non-module) scripts loaded
before vanilla-grid-toolbar.js so <grid-minimal-theme-selector> and
<grid-minimal-rows-selector> are upgraded before the toolbar's first synchronous
render — the initial empty template, which both selectors live in. That
first render happens inside
vanilla-grid-toolbar.js's own upgrade reaction, before any deferred
type="module" script (like app.js) has run, so a sub-component defined there
would still be an un-upgraded plain HTMLElement at that render and never
receive the linked grid. (The toolbar does now transition between empty and
selected, re-cloning on each switch — but the initial empty render must already
have working selectors.) theme-selector.js also defines
showLoadingOverlay() / hideLoadingOverlay(), consumed by app.js; and
app.js publishes window.sf5CurrentRowCount in the other direction, for
rows-selector.js to re-sync from. See the header comment in
theme-selector.js for the full explanation.
prefs.js loads before both of them, for a related but separate reason: each
selector reads a stored preference while building its dropdown, and the theme
restore has to rewrite the <vn-grid theme="…"> attribute before the element
upgrades. It reaches window.localStorage directly instead of through the
component's VanillaGridLocalStorageProvider, which is one of the modules
vanilla-grid.js auto-loads asynchronously and so does not exist yet at that
point in parsing.
Run
Serve the workspace root with any static server, then open
/samples/grid-minimal-js/index.html. No backend or network access is required —
all data is generated in the browser.
Test hook: Playwright overrides the starting row count via a
?rows=<n>query param (seetests/playwright/column-filters.spec.js,tests/playwright/sample-search-box.spec.js) to keep test memory down and pin an exact dataset size. It wins over the toolbar dropdown, which renders nothing while it is present — which also means such a visit never changes the stored size. Without it the demo starts at the last size picked (1,000 on a first visit) and the dropdown is in charge.
Files
index.html— grid + toolbar markup, loading overlay, script load order.app.js—type="module"bootstrap: awaitsVanillaGridReady, registers the Apple theme, generates rows, wires theStaticDataManager, initializes the grid, and handlesgrid-minimal-js:rowcountchangeby regenerating the dataset.prefs.js— plain script, loaded before the two selectors: the sample's ownlocalStorage-backed preference store (window.GridMinimalPrefs) for the selected theme and row count.theme-selector.js— plain script:<grid-minimal-theme-selector>custom element + overlay helpers (loaded before the toolbar for the timing reason above).rows-selector.js— plain script:<grid-minimal-rows-selector>custom element, the row-count dropdown (loaded before the toolbar for the same reason). Emitsgrid-minimal-js:rowcountchange;app.jsowns the regeneration.styles.css— layout and loading-overlay styles; setsoverscroll-behavior-y: noneonhtml/bodyalongside the100dvhapp shell — the host-owed half of the grid's mobile touch contract that stops document-level rubber-band bounce and pull-to-refresh on touch devices (seedocs/vanilla-grid/00-index.md, "Mobile / touch integration").themes/vn-grid-apple.css— the frontend-local Apple grid theme stylesheet.themes/vn-grid-toolbar-apple.css— the matching Apple toolbar theme stylesheet (companion to the grid one; registered on the toolbar inapp.js).