Themes Implementation in Vanilla-Grid

This document explains how theming works in Vanilla-Grid at implementation level: what belongs to the base stylesheet, what themes override, the CSS custom-property contract, and how runtime theme switching is performed.


1. Architecture: Base vs Theme Layers

Vanilla-Grid styling is intentionally split into two layers:

  1. Base structure (vanilla-grid.css)

    • Owns layout, positioning, sizing, and interaction mechanics
    • Defines non-visual behavior for virtualization, sticky frozen columns, resize guides, and custom scrollbar geometry
    • Provides fallback custom properties for context menus and frozen columns
  2. Theme files (themes/vn-grid-*.css)

    • Own colors, typography, borders, shadows, and hover/selected visual states
    • Override CSS custom properties consumed by the base layer
    • Do not replace core layout mechanics

This separation allows changing appearance without destabilizing grid behavior.


2. Base CSS Responsibilities (vanilla-grid.css)

The base stylesheet contains the structural contract used by all themes:

2.1 Important Rule

Base CSS defines how elements behave (position, stacking, flow). Themes define how they look (palette, typography, emphasis).

Example: frozen cells in base CSS are always sticky with opaque background to avoid bleed-through during horizontal scroll.

2.2 The header-text ellipsis floor

.vn-grid-header-text carries min-width: 2em, and that value is load-bearing rather than cosmetic. CSS Overflow permits an engine to fall back to clip when there is not enough room to paint the ellipsis, and Firefox exercises that option: once the box is narrower than the shortest string it could ellipsize to — one glyph plus U+2026 — it stops ellipsizing altogether and hard-clips mid-word. The user-visible result is that narrowing a column past that point makes the header show more characters and no (ActiveA…ActiAct). Chromium keeps painting the ellipsis and never exhibits it, so this is invisible in a Chromium-only test matrix.

The threshold is font-dependent — in SAP's 72 at 14px the ellipsis alone is a full 14px, so A… needs 23.3px — which is why the floor is expressed in em rather than pixels. 2em clears the threshold with margin in every shipped theme and additionally stops the label collapsing to zero width (a blank header) in very narrow columns.

A theme that overrides min-width on .vn-grid-header-text re-opens the defect. The cost of the floor is that a sorted column whose label is narrower than 2em renders its sort chevron up to 2em from the text rather than hard against it, since min-width floors the box above the text's own width.

The header label is one flex row — the text wrapper (label and sort indicator), then .vn-grid-header-secondary-text, then the aggregate badge (§ 4.8, The header aggregate badge). In a narrow column they give way in that order: the label ellipsizes down to its 2em floor, then the secondary text ellipsizes (min-width: 0; overflow: hidden; text-overflow: ellipsis), and the badge never shrinks. Auto-fit sizes the row as the sum of those parts, so it relies on the same structure (see Column Resizing).

.vn-grid-header-table th.vn-grid-frozen-col,
.vn-grid-body-table td.vn-grid-frozen-col {
  position: sticky;
  z-index: 2;
  background: var(--vn-grid-frozen-bg, #ffffff);
}

Theme files only change the variables (--vn-grid-frozen-bg, etc.), not the sticky behavior.

Header background-effects slot (th::before)

Every header th has a ::before pseudo-element generated by the base CSS. This is the only place where visual background colors (hover tint, sorted overlay) may be painted:

/* Base CSS — generated on every th */
.vn-grid-header-table th::before {
  content: '';
  position: absolute;
  top: 0; left: 0; right: 12px; bottom: 0; /* stops before the resize handle */
  z-index: 1;    /* above th background, including frozen-header bg */
  pointer-events: none;
}

Stacking within a header cell (all z-indexes local to the th stacking context):

Layer z-index What
th own background 0 frozen bg, thead bg
th::before slot 1 hover tint, sorted overlay
.vn-grid-header-label 2 text, sort arrow, secondary header text, aggregate badge
.vn-grid-col-resizer 3 resize handle & visual line

Implications for theme authors:


3. Theme Contract (Selectors You Should Override)

All 8 official themes (vn-grid-default.css, vn-grid-material.css, vn-grid-fiori.css, vn-grid-carbon.css, vn-grid-carbon-dark.css, vn-grid-glow.css, vn-grid-glow-dark.css, vn-grid-fluent.css) consistently style these areas:

  1. .vn-grid-table-container
  2. .vn-grid-header-spacer
  3. .vn-grid-header-table thead, .vn-grid-header-table th
  4. .vn-grid-header-secondary-text
  5. .vn-grid-col-resizer, .vn-grid-col-resizer::after
  6. .vn-grid-sort-indicator + th.vn-grid-sorted*
  7. .vn-grid-virtual-list-viewport, .vn-grid-body-table, row/cell selectors
  8. .vn-grid-empty-message
  9. .vn-grid-skeleton-row, .vn-grid-skeleton-shimmer
  10. .vn-grid-table-container, .vn-grid-header-context-menu (via CSS vars — dual selector required, see §4.1)
  11. .vn-grid-table-container frozen-column custom properties
  12. Column filter panel — .vn-grid-header-context-menu.vn-grid-filter-panel (via --vn-grid-filter-* vars; defaults inherit the menu look)
  13. Header filter funnel icon — .vn-grid-table-container (via --vn-grid-filter-icon-* vars)
  14. Row grouping — captions, the group bar and its chips (via --vn-grid-group-* vars, § 4.8). Unlike every other entry in this list, the base stylesheet holds no appearance here beyond a neutral fallback, so this is the one area where each theme is expected to differ from the others rather than agree with them. Keep it in a single self-contained block, as every shipped theme does.

themes/TEMPLATE-vn-grid-theme.css is the canonical checklist for this contract.

Row-hover rule shape: every theme's .vn-grid-body-table tbody tr:hover rule must keep the :not(:has(.vn-grid-empty-message)) exclusion (.vn-grid-body-table tbody tr:hover:not(:has(.vn-grid-empty-message)) { background: ... !important; }). The empty-message row (showEmpty() — see the "Empty-state exception" note in §1.9 of the row-virtualization doc) holds no data, so a row-hover tint reading on it is a rendering glitch, not selection feedback — dropping the exclusion in a new theme silently reintroduces that glitch.


4. CSS Custom Properties Used by Core Features

4.1 Header Context Menu Variables

Defined (with defaults) in base CSS, and normally overridden per theme, on both .vn-grid-table-container and .vn-grid-header-context-menu:

These are consumed directly by the context menu component that is created at runtime by grid JS, and are shared by the filter panel (which reuses the same element) — so font, radius, background, and the native date-picker color scheme stay coherent between the two.

Load order. The base stylesheet declares the --vn-grid-menu-* defaults on .vn-grid-table-container, .vn-grid-header-context-menu and themes override on the same selector pair (equal specificity), so the base <link> must precede the theme <link> in <head>. <vn-grid> guarantees this in _updateThemeStylesheet() (it injects the base first). If you load the CSS manually, keep vanilla-grid.css before the theme file.

The menu and filter panel are appended to document.body, not to the grid container, so they can't inherit a per-grid theme through the normal CSS cascade — a .vn-grid-table-container scope can't reach an element that lives outside it. header-menu.feature.js reads the tokens above off the owning grid's .vn-grid-table-container via getComputedStyle() when the menu (or filter panel) opens, and writes them as inline custom properties on the menu element. Inline style always beats a stylesheet rule, so the menu tracks its own grid's theme rather than whatever the bare .vn-grid-header-context-menu class cascade resolves to. This is why the token block above must be declared on .vn-grid-table-container as well as the bare menu class: a theme that only declares it on the menu class has nothing for that read to find and copy, so its menu falls back to whatever the container resolves to instead (no dedicated fallback path — see themes/README.md, "Header Context Menu Tokens").

This also fixes a real, pre-existing bug: --vn-grid-filter-accent and --vn-grid-filter-apply-color used to always resolve to their neutral defaults, never a theme's override, because the default (declared on the more specific .vn-grid-header-context-menu.vn-grid-filter-panel compound selector) outranked a theme's override (declared on the plain menu class) by specificity, regardless of <link> order. Both are now declared on the same dual selector as the rest of the menu tokens, so a theme's override wins on equal footing.

Known limitation — does not fix multi-instance, differently-themed pages. .vn-grid-table-container is a plain, page-global class with no per-instance CSS scoping (no Shadow DOM, no @scope, no attribute-keyed selectors tying a container to its theme's stylesheet). On a page running two <vn-grid> instances on genuinely different themes at the same time, every loaded theme stylesheet matches every container equally — both grids' containers (and therefore this copy step) resolve to whichever theme's <link> is last in <head>, same as the pre-existing bug this was meant to fix. This is a wider, pre-existing gap in container-level theming (not introduced by the copy step, and not solved by it either — a real fix would need each grid to read its own already-tracked theme <link>'s stylesheet rules directly, rather than the shared container's computed style). The copy step above is correct and non-regressing for the common case covered by this codebase today: a page using one active theme at a time (all built-in themes agree on every container), or a single grid switching themes over time.

4.2 Frozen Column Variables

Provided on .vn-grid-table-container by each theme:

These variables are used by base selectors and keep freeze rendering consistent across themes.

4.3 Row Height Variable

--vn-grid-row-height is declared on .vn-grid-table-container by every built-in theme and represents the theme's preferred body row height (in pixels). Built-in defaults align with each design system:

Theme Value
vn-grid-default 24px
vn-grid-fiori 32px
vn-grid-fluent 32px
vn-grid-glow(-dark) 32px
vn-grid-material 36px
vn-grid-carbon(-dark) 48px

Carbon's row height matches its header height (§4.4) rather than sitting on a different tier — Carbon's own data-table sizes apply one row-height value to both the header and body rows of a given table.

Resolution order at runtime:

  1. If the host passes layout.rowHeight to initializeGrid() or sets the row-height attribute on <vn-grid>, that value wins and is written back to the container as an inline --vn-grid-row-height override.
  2. Otherwise the grid reads the active theme's --vn-grid-row-height and uses it as both the virtualization row height and the CSS value (so checkbox sizing, last-row clamps, etc. stay in sync).
  3. If no theme declares the variable, the grid falls back to the historical default of 24px.

The base stylesheet uses --vn-grid-row-height for checkbox-cell max-height and selection-checkbox clamp() sizing.

The theme's declared value is a minimum, not the truth. --vn-grid-row-height on a <td> only sets a minimum height — real cell content (padding, a custom renderCell control such as a gauge or rating widget) can need more room than the theme author accounted for. measureActualRowHeight() (02 § 1.14) corrects for this by measuring a live rendered row after the first data load. Because the theme <link> loads asynchronously (§6.1.2), it can finish loading after that first content-driven correction has already happened; when its own load callback re-reads --vn-grid-row-height from the now-loaded stylesheet, it re-measures immediately (in the same callback, synchronously) rather than trusting the theme's declared value outright — otherwise it would silently overwrite a correct, content-aware height with an under-sized one.

That re-measurement passes { fromBaseline: true }, and the callback additionally waits for the base stylesheet, not just the theme's (02 § 1.14). Both matter: a row measured before vanilla-grid.css applies reports an inflated, font-dependent height, and because the <td> height is a table-row minimum that inflated value pins the rows to itself, so an ordinary re-measurement would read it straight back and confirm it permanently.

grid-minimal-js's SAP Fiori theme is a real instance: it declares 32px, but the sample's Height column (a 20px gauge renderCell) plus the theme's 8px vertical cell padding settles at 37px.

4.4 Header Height Variable

--vn-grid-header-height is declared on .vn-grid-table-container by every built-in theme and represents the theme's preferred column-header height (in pixels). Built-in defaults align with each design system:

Theme Value
vn-grid-default 32px
vn-grid-glow(-dark) 40px
vn-grid-fluent 42px
vn-grid-fiori 44px
vn-grid-carbon(-dark) 48px
vn-grid-material 56px

Resolution order at runtime (mirrors --vn-grid-row-height):

  1. If the host passes layout.headerHeight to initializeGrid() or sets the header-height attribute on <vn-grid>, that value wins and is written back to the container as an inline --vn-grid-header-height override.
  2. Otherwise the grid reads the active theme's --vn-grid-header-height (when declared) so each theme can specify a column-header height that matches its design system.
  3. If no theme declares the variable, the base stylesheet's 32px default applies via CSS.

The base stylesheet applies var(--vn-grid-header-height, 32px) as the height of every .vn-grid-header-table th, so themes only need to declare the variable (no extra selectors required).

4.5 Drop Indicator Variables

Set on .vn-grid-header-table th in theme files:

They control drag-and-drop insertion marker visuals without changing reordering logic.

The group bar's chips paint the same marker when a chip is dragged to re-nest a group level, and read the same defaults — but they are a sibling of the header rather than inside it, so they cannot inherit these declarations. See § 4.8 for the --vn-grid-group-chip-drop-* tokens that bridge the two, and for the one shipped theme that has to restate a value here.

4.5.1 Sort Icon Tokens

Also set on .vn-grid-header-table th. Unlike most theme tokens these are required with no base default — the base stylesheet consumes them with bare var() (no fallback), because there is deliberately no "standard" sort icon: every theme owns its full set. A theme that omits a content token renders no sort glyph at all under that theme (the declaration is invalid at computed-value time → content: normal, no pseudo-element box) — loudly visible rather than silently masked. tests/node/sort-icon-theme-tokens.test.js statically asserts every shipped theme (and the TEMPLATE) declares all five.

The obligation is on the stylesheet, not on where it lives: a theme registered at runtime with VanillaGridElement.registerTheme() from outside src/ — e.g. grid-minimal-js's frontend-local Apple theme — consumes the same base rules and must declare the same five tokens. The static guard therefore also scans hand-authored samples/<app>/themes/vn-grid-*.css (generated dist/, vendor/ and public/ trees are excluded, being copies rather than sources).

Two further tokens are optional — both default to 0, so a theme that never mentions them renders exactly as it always has:

Every shipped theme now carries a measured value, because the uncorrected glyphs were visibly off the header text's line: Carbon's / sat 2.5px below it and Material's SVGs 2.5px above (ascending) and 2px below (descending). sort-indicator-centring.spec.js re-measures all nine themes in both directions at 1x and 8x and fails past 1.25px. It compares the glyph's ink against .vn-grid-header-textnot .vn-grid-header-label, which wraps the indicator — and isolates each element by differencing the header cell with it hidden against the cell with it shown, because a sorted header is filled with the theme's accent in several themes and no luminance threshold can tell ink from background there.

The values were chosen to minimise the worst case across 1x, 2x and 8x rather than to perfect any one of them: a correction is a sub-pixel shift, and at 1x it is either dropped or applied as a whole device pixel, so a value that measures perfect on a fine raster can be a pixel out on an ordinary monitor. Where a theme's error was already under half a pixel, the chosen value is deliberately small enough to round to zero at 1x — it improves 2x and 8x and leaves the 1x rendering untouched.

They exist for the same reason the group chip's -icon-offset-y does (§ 4.8): vertical-align: middle places the glyph's box, not its ink, and where the ink sits inside that box depends entirely on the theme's font and glyph. The error scales with the glyph's size, so the theme that sizes its indicator up is the one that needs the correction — the sample's vn-grid-apple.css sets its indicator to 20px (U+2303/U+2304 draw small outside SF Pro, and 11px left it at 3.3px of ink against other themes' ~6px triangles), and at that size its sat 6.1px above the header's centre line and its 3.4px below. Hence a value per direction: they are different characters, and a font gives no guarantee they sit alike. The transform lands on the indicator span, an inline-block, rather than on the ::before, which is an inline box that transform does not apply to. Re-measure whenever the indicator's font-size changes — the values are in em and track it.

Affordance visibility matrix (sortable / opacity / hover-opacity):

Behavior sortable opacity hover-opacity
No affordance (default, fluent, fiori, glow, glow-dark) none 0 0
Hover-only (Carbon light + dark, Material) glyph 0 1
Always visible, dimmed glyph 0.4 1
Always visible, full glyph 1 1

Values accept any valid content value — a quoted glyph string, none, or url("data:image/svg+xml,…"). Note url() images do not follow currentColor, so themes that recolor the sorted glyph (both Carbons flip it to their on-accent foreground) should prefer text glyphs. The Carbon themes use text glyphs (hover-only affordance, / direction glyphs, colored by the on-accent flip); the Material theme is the first url() consumer — an alphabetical "AZ" SVG glyph (both-triangle hover-only affordance in the muted header gray, single-triangle asc/desc glyphs in the theme accent), with the colors baked into each SVG since replaced images ignore currentColor (safe for Material, whose sorted header keeps a light tint rather than an accent-fill flip; all three SVGs share one 16×20 intrinsic size — Material also enlarges the indicator box to match — so state changes cause no layout shift). The sorted direction rules pin opacity: 1 and override the affordance by specificity, so a sorted column never shows both glyphs. The affordance toggles via opacity only, inside the indicator's reserved min-width box, so hover causes no header layout shift.

Migration note for custom themes: themes written before these tokens existed declare none of them and therefore lose their sort glyphs entirely when used with this version — copy the token block from themes/TEMPLATE-vn-grid-theme.css (or any shipped theme) into the theme's .vn-grid-header-table th rule.

4.6 Column Filter Panel & Filter Icon Variables

These cover the filtering UI added alongside the context menu. Scope matters because the two elements live in different parts of the DOM:

Filter panel — the panel reuses the context-menu element and is appended to document.body. Two tokens are independent brand colors, not derived from the menu look, so (like --vn-grid-menu-*) they're declared on the same dual selector — .vn-grid-table-container, .vn-grid-header-context-menu — described in §4.1, and are covered by the same open-time container→menu copy:

The rest default to their --vn-grid-menu-* (or --vn-grid-filter-accent) equivalent via var(), declared on the element's own class, .vn-grid-header-context-menu.vn-grid-filter-panel — once the two tokens above are correctly scoped to the owning grid, these resolve correctly too with no separate container copy needed. Override one only to make the filter panel differ from the menu:

Filter funnel icon — the icon lives inside the header (.vn-grid-header-table th), so its tokens are set on .vn-grid-table-container alongside the frozen/row-height tokens:

The glyph is rendered as a masked ::before on the funnel button: the mask sets the shape (--vn-grid-filter-icon[-active]) and the button's color sets the paint (--vn-grid-filter-icon-color / -active-color, via currentColor). The button element itself carries no SVG markup — the shape is entirely CSS-owned, so each theme can ship its own funnel. By default a theme uses the same lucide funnel the toolbar uses for its filter command, so the header funnel and the toolbar funnel match out of the box. The Carbon themes (light + dark) override the shape with IBM Carbon's own filter icon (32×32 grid, squared funnel with a straight stem) — outline at rest, solid silhouette when active. The Material theme overrides it with the Google filter_alt silhouette (wide triangular funnel tapering into a short stem), likewise outline at rest and filled when active — authored with a taller-than-wide 20×24 viewBox and paired with a Material-only enlargement of the glyph's ::before box (18×21 instead of the base 16×16 square) so the funnel renders taller. The mask reads only the SVG's alpha channel, so a swapped-in glyph needs no particular fill color; the paint tokens keep working unchanged (including the sorted-header on-accent flip below).

All built-in themes set --vn-grid-filter-accent / --vn-grid-filter-apply-color and --vn-grid-filter-icon-active-color to their design-system accent; themes/TEMPLATE-vn-grid-theme.css documents both blocks.

Sorted-header caveat. If a theme paints the sorted header (th.vn-grid-sorted::before) with a solid accent fill — as the Carbon / Carbon Dark themes do, flipping the header text and sort indicator to an on-accent foreground — then the active funnel, which is normally that same accent color, would become invisible against that background on a column that is both sorted and filtered. Such a theme must flip the active funnel to the on-accent foreground as well, alongside its sort-indicator flip:

.vn-grid-header-table th.vn-grid-sorted .vn-grid-filter-icon.vn-grid-filter-icon-active {
    color: <on-accent-foreground>;
}

Themes that use a subtle tint for the sorted background (default / material / fiori) keep the accent funnel legible and need no such override.

4.7 Checkbox styling

Row-selection, select-all-header, and read-only boolean-column checkboxes are native <input type="checkbox"> elements, so they are themed with the standard accent-color property (no custom markup). accent-color colors all three states coherently — the checked fill and check mark, the indeterminate dash on the select-all header (partial selection), and the box border when unchecked — and the browser auto-picks a contrasting glyph color. Target all three classes:

.vn-grid-selection-checkbox,
.vn-grid-selection-checkbox-header,
.vn-grid-boolean-checkbox {
    accent-color: #161616;        /* checked/indeterminate fill */
}

Dark themes should additionally set color-scheme: dark on the same selectors so the browser renders the unchecked box's border legibly on the dark background instead of a near-invisible dark-on-dark outline. Pick the accent-color to suit the design system: a near-white fill where the spec wants a white box with a dark check (Carbon), or the theme's brand accent where a colored box reads better (Glow). The base stylesheet only sets a sizing clamp() and a neutral blue accent on the header box, so a theme's accent rule (equal specificity, loaded later) wins.

Every built-in theme sets a brand-accent checkbox to match its design system (accent-color = the theme's --vn-grid-filter-accent), except vn-grid-default.css, which intentionally keeps the standard native checkbox. vn-grid-carbon(-dark).css and vn-grid-glow-dark.css are the reference implementations for the light and dark patterns.

4.8 Row grouping — a fully theme-owned surface

Row grouping is the one feature where vanilla-grid.css ships no appearance of its own beyond a neutral fallback. The base owns only mechanics — flow, hit-areas, focus plumbing, ellipsis, [hidden] — and every property that decides how the caption rows, the group bar and its chips look reads a custom property. Each shipped theme carries a single self-contained Row Grouping — captions, group bar and chips block, and they differ substantially from one another on purpose.

Why this feature and not the others. A squared IBM Carbon chip, a Material capsule with elevation and no stroke, and a Fluent 4px card are all correct, and no single shared look is right for more than one of them. The other surfaces in this document tolerate a shared skeleton because the design systems agree about them (a header cell is a header cell); grouping chips are where they visibly do not. So the split is drawn harder here: do not add paint to the base stylesheet for grouping. A value only one theme wants belongs in that theme, and a value every theme wants still belongs in each of them, because the next theme is the one that will want to differ.

Fallbacks are still guaranteed. Every token below is optional and falls through to a neutral value, so a custom theme that never mentions grouping gets a usable bar rather than a broken one. That is a functional requirement, not tidiness: grouping removes a grouped column from the header, and the bar is the only place its chip can be removed from (see Row Grouping Implementation § 18), so an unstyled bar would be a dead end. tests/playwright/grouping-bar-theming.spec.js pins both halves — that the themes genuinely diverge, and that the fallbacks still produce a working bar when every token is unset.

Caption rows

Token Falls back to
--vn-grid-group-caption-bg rgba(0, 0, 0, 0.035)
--vn-grid-group-caption-fg inherit
--vn-grid-group-caption-font-family / -font-size inherit
--vn-grid-group-caption-font-weight 600
--vn-grid-group-toggle-gap 6px
--vn-grid-group-toggle-icon-expanded / -collapsed SVG url() masks: a down / right chevron
--vn-grid-group-toggle-icon-size / -color 1em / inherit
--vn-grid-group-indent 20px (applied in rendering.feature.js)

The toggle marker is a masked SVG, not a font glyph. The caption toggle's ::before is an empty 1em box painted background-color: currentColor and shaped by mask: var(--vn-grid-group-toggle-icon-expanded) (swapped to -collapsed under aria-expanded="false") — the same mechanism as the aggregate marker icons and the header funnel. A character rendered through content takes its weight, size and vertical position from whichever font the theme's stack resolves the code point to; / came out small and sitting low next to the caption label in some themes (Carbon dark among them). A mask has no font dependency. The two tokens therefore take an SVG url(); a string value is not a valid mask image and renders no marker at all — a custom theme that used to set '▾'/'▸' must switch to a url("data:image/svg+xml,…") or simply delete the declaration. The defaults exist only as var() fallbacks in the base stylesheet: chevrons in a 24×24 viewBox, fill='none', stroke 2.5 with round caps and joins, matching the aggregate icons. -size sets the icon box's width and height (not a font-size); -color sets color, which the mask paints through currentColor. In right-to-left layouts the collapsed icon is mirrored (transform: scaleX(-1), so it points toward the reading direction; a theme-supplied collapsed icon is mirrored too), and in forced-colors mode the box paints ButtonText, since the forced system background would otherwise erase it.

Every shipped theme uses the base chevrons except these. The default, Glow and Glow Dark themes override the pair with filled triangles (▼ expanded / ▶ collapsed, drawn as masked SVGs with softened corners). The two Carbon themes override it with a boxed minus (expanded) / boxed plus (collapsed), the tree-view expand/collapse control, and enlarge -size to 1.2em: a square reads smaller than a chevron in the same box.

Reusing the theme's row-hover / --vn-grid-frozen-bg-hover value for -caption-bg is a reasonable default: a caption is a structural, non-interactive-until-clicked row of similar visual weight. --vn-grid-group-indent is the per-level indent step for nested grouping, applied as padding-inline-start on the caption toggle — never a physical indent column.

The summary row a grouped grid renders per group per nesting level once a column carries an aggregate — absent entirely while none does. A caption opens a group and a footer closes it, so the defaults are a lighter tint than the caption's plus a rule above the row; two declarations are usually all a theme needs.

Token Falls back to
--vn-grid-group-footer-bg rgba(0, 0, 0, 0.02)
--vn-grid-group-footer-fg inherit
--vn-grid-group-footer-font-weight 600
--vn-grid-group-footer-border-top-width / -border-top-style 1px / solid (0 removes the rule)
--vn-grid-group-footer-border-top-color rgba(0, 0, 0, 0.12)
--vn-grid-group-footer-marker-color inherit
--vn-grid-group-footer-marker-gap 6px

The tint is composited as a linear-gradient over --vn-grid-frozen-bg with :nth-child(even) and :hover variants, exactly as the caption's is and for the same three reasons (a translucent tint must not let the freeze guide through, frozen cells paint their own opaque background at the freeze boundary, and the zebra-stripe rules would otherwise repaint a pooled footer back to the stripe colour as it scrolls — which reads as a flicker). A theme that reaches for a plain background-color here reintroduces all three.

-fg and -font-weight are applied to the value span, not to the row's cells, because auto-fit measures a probe carrying that same class in an ordinary cell of the column — putting the typography on the span is what makes the probe inherit the footer's metrics.

The function marker is an icon. A built-in function's marker is an empty .vn-grid-aggregate-icon span with data-fn="<fn>", painted in currentColor (so --vn-grid-group-footer-marker-color still colours it) through a masked SVG, the same mechanism as the header filter funnel. Font glyphs were dropped because Σ μ ↓ ✓ rarely resolve to one font: the ones the theme font lacks fall back to a system symbol font with its own weight, bearings and baseline, which is how markers came out too heavy or off-centre in some themes. The shapes are declared on .vn-grid-table-container in vanilla-grid.css, and the shipped themes use them as they are; a theme overrides any of them by redeclaring the token.

Token Default shape
--vn-grid-aggregate-icon-sum Σ
--vn-grid-aggregate-icon-avg
--vn-grid-aggregate-icon-median
--vn-grid-aggregate-icon-min arrow down onto a baseline ()
--vn-grid-aggregate-icon-max arrow up onto a top line ()
--vn-grid-aggregate-icon-countTrue check mark
--vn-grid-aggregate-icon-countDistinct #
--vn-grid-aggregate-icon-size 1em

A replacement follows the defaults' conventions: a 24×24 viewBox, stroked with round caps and joins at stroke-width='2.5' (heavier than the funnel's 2, because these render at about 1em beside a 600-weight value). Only the mask's alpha matters, so the stroke colour is irrelevant. min/max point to a bound rather than using plain arrows, so they don't read as a sort direction. The span sits at vertical-align: -0.125em, which puts its centre on the centre of the digits beside it. In forced-colors mode it paints with CanvasText, since the forced background would otherwise erase the mask.

What a marker means is not a theme's business: the function's name is a message (aggregateFunctionLabels), because it is what the row's accessible name and the tooltips say and a theme cannot know the locale. A host that wants text instead of an icon sets messages.aggregateFunctionMarkers, which is also how a function registered with VanillaGrid.registerAggregate() gets a marker at all (see Localization Implementation § 3.4). The focus ring is not a token either: it reuses the caption's --vn-grid-group-focus-* tokens, because a footer's focus ring and a caption toggle's are the same affordance in the same grid, and nothing is served by letting a theme make them disagree.

The header aggregate badge

A column whose footer cells show a total carries the same marker in its header: .vn-grid-aggregate-badge, a pill after the secondary header text, holding the same .vn-grid-aggregate-icon (or a host's text marker). Inside the badge the icon is 1.25em of the badge's own font size, i.e. the header text's size at the default 0.8em, since 1em of the smaller badge font read too small. So --vn-grid-aggregate-badge-font-size scales both the pill and the icon. The badge carries a data-fn attribute naming its function, which the base stylesheet does not use but a theme may style per function.

Token Falls back to
--vn-grid-aggregate-badge-color inherit
--vn-grid-aggregate-badge-bg color-mix(in srgb, currentColor 14%, transparent)
--vn-grid-aggregate-badge-radius 999px
--vn-grid-aggregate-badge-padding-x 5px
--vn-grid-aggregate-badge-font-size 0.8em

The background deliberately does not fall back to --vn-grid-filter-accent. Every shipped theme sets that token to a saturated brand colour for the filter panel's Apply button, and inherited header text on that fill is unreadable. The shipped themes tint the accent instead (color-mix(in srgb, var(--vn-grid-filter-accent) 16%, transparent)) and keep the header's own text colour for the icon. vn-grid-default.css is the exception: its header is the accent colour, so it uses a light wash (rgba(255, 255, 255, 0.22)).

The group bar

The strip above the column header carrying one chip per applied group level. It occupies space only while a group state exists.

Token Falls back to
--vn-grid-group-bar-bg / -fg rgba(0, 0, 0, 0.03) / inherit
--vn-grid-group-bar-border rgba(0, 0, 0, 0.12)
--vn-grid-group-bar-border-width / -border-style 1px / solid (0 removes the rule)
--vn-grid-group-bar-height 34px (min-height)
--vn-grid-group-bar-padding / -gap 4px 8px / 6px
--vn-grid-group-bar-shadow none
--vn-grid-group-bar-font-family / -font-size inherit / 0.85em
--vn-grid-group-bar-suspended-opacity 0.6

A suspended group bar (a requested grouping the grid cannot currently apply) carries data-suspended="true" on the strip. Its chips stay interactive — removing a level the grid cannot apply must not require first fixing the dataset — so the treatment must read as inactive without reading as disabled.

The "Grouped by" lead-in and the chip separator

Token Falls back to
--vn-grid-group-bar-label-display inline (none hides it)
--vn-grid-group-bar-label-color / -font-size inherit
--vn-grid-group-bar-label-font-weight 600
--vn-grid-group-bar-label-transform / -letter-spacing none / normal
--vn-grid-group-bar-label-opacity 0.75
--vn-grid-group-bar-separator-display inline (none hides it)
--vn-grid-group-bar-separator-content / -content-rtl '›' / '‹'
--vn-grid-group-bar-separator-opacity 0.5

--vn-grid-group-bar-label-display: none hides the lead-in visually only — the text stays in the DOM, so nothing is lost to a screen reader. Use it for a bare chip-row look (vn-grid-material.css does, and drops the separator too). The separator glyph is logical-direction aware: the -content-rtl variant is used under [dir="rtl"], so no theme needs an RTL rule of its own.

Chips

Token Falls back to
--vn-grid-group-chip-bg / -fg rgba(0, 0, 0, 0.05) / inherit
--vn-grid-group-chip-border rgba(0, 0, 0, 0.18)
--vn-grid-group-chip-border-width / -border-style 1px / solid (0 = frameless)
--vn-grid-group-chip-radius 12px (0 squares it, 999px makes a capsule)
--vn-grid-group-chip-padding 2px 4px 2px 8px
--vn-grid-group-chip-min-height / -gap auto / 4px
--vn-grid-group-chip-shadow none (elevation instead of a stroke)
--vn-grid-group-chip-font-family / -font-size / -font-weight inherit
--vn-grid-group-chip-label-font-weight inherit (the chip's own weight)
--vn-grid-group-chip-label-transform / -letter-spacing none / normal
--vn-grid-group-chip-button-size 18px (square hit-area)
--vn-grid-group-chip-button-radius 50% (0 to match a squared chip)
--vn-grid-group-chip-button-color / -hover-color inherit
--vn-grid-group-chip-button-hover-bg --vn-grid-group-chip-border
--vn-grid-group-chip-cursor grab
--vn-grid-group-chip-drop-reach 6px
--vn-grid-group-chip-drop-overhang 4px
--vn-grid-group-chip-drop-width --vn-grid-drop-tip-width12px
--vn-grid-group-chip-drop-height --vn-grid-drop-tip-height6px
--vn-grid-group-chip-drop-color --vn-grid-drop-tip-colorcolor-mix(in srgb, currentColor 48%, transparent)

A chip reads as raised on the strip, so give it the theme's plain surface color rather than the strip's own tint.

A chip is draggable within the bar to re-nest its group level, and the last five tokens are that gesture's paint. The cursor is its affordance. The insertion marker is the same pair of facing tips a column-header drop paints (§ 4.5 / the --vn-grid-drop-tip-* family), centred on the logical edge of the chip the drop would insert next to — one marker per boundary, since "before N" and "after N−1" are the same slot. Each of its four values falls back to your --vn-grid-drop-tip-* value and then to the header's own default, so you normally declare none of them: the chip marker already matches the header one.

The one case that needs a restatement is a theme whose header tips are not the shared currentColor mix. Those tokens are declared on .vn-grid-header-table th, and the group bar is a sibling of the header rather than a descendant, so a chip cannot inherit them — the sample's vn-grid-apple.css therefore repeats its #007aff as --vn-grid-group-chip-drop-color in its grouping block, and is the only shipped theme that has to.

The two remaining tokens size the bracket to a chip rather than to a header cell, and both have a ceiling worth knowing.

--vn-grid-group-chip-drop-reach is a margin-inline on the chip, added on top of --vn-grid-group-bar-gap: the marker is centred on the chip's edge and so is half outside it, and this is the clearance that puts that half in whitespace rather than over a neighbouring chip. Set it to 0 only if you also widen the bar gap enough to hold the tips.

--vn-grid-group-chip-drop-overhang is how far the bracket reaches past the chip's top and bottom. It exists because a chip is short: drawn inside the chip's own box the tips sit over its background and read as decoration rather than as an insertion point. Raise it only if your bar's vertical padding is larger than the 4px default leaves room for — the strip clips (overflow-x: auto computes overflow-y to auto), so an overhang bigger than --vn-grid-group-bar-padding's vertical value minus the chip's own slack is simply cut off.

The dragged chip itself is dimmed by the base stylesheet to the same 0.55 a dragged column header uses, and the drag image is an opaque clone of the chip that inherits every token above — both are mechanics, not tokens.

The label's weight is its own token, deliberately. -font-weight sets the whole chip, and the chip's buttons take it through font: inherit — so raising it to make the column's name read as a name thickens the direction arrow with it. --vn-grid-group-chip-label-font-weight applies to the name alone, mirroring --vn-grid-group-bar-label-font-weight for the "Grouped by" lead-in. Every shipped theme sets it in its own vocabulary — 600 for the SemiBold families (Carbon's IBM Plex, Fluent's Segoe, Fiori, Glow, the default theme), 700 for Material, whose Roboto has no 600 face and whose chip text is already 500, and 590 for the sample's Apple theme, the weight it gives every other grouping label. It falls back to inherit, so a theme that says nothing keeps the chip's weight.

Direction and remove glyphs

Token Falls back to
--vn-grid-group-chip-icon-asc / -icon-desc '▴' / '▾'
--vn-grid-group-chip-icon-size 1em
--vn-grid-group-chip-icon-offset-y 0
--vn-grid-group-chip-icon-desc-offset-y --vn-grid-group-chip-icon-offset-y
--vn-grid-group-chip-remove-icon-size 9px (arm length of the drawn ✕)
--vn-grid-group-chip-remove-icon-thickness 1px

The direction glyph uses the same mechanism as the header's sort glyphs (§ 4.5.1): the base supplies the slot, the theme supplies the content, and a string or a url() SVG both work — vn-grid-material.css uses the latter on the chip as well as in the header.

The remove affordance does not take a glyph at all — the base draws it as two hairlines crossed at ±45°, painted in currentColor so --vn-grid-group-chip-button-color and its -hover-color still own the ink. A theme sizes it and leaves it alone; there is nothing to centre and nothing to measure. See the -offset-y note below for why it is drawn rather than typed.

Making a chip's arrow read as the column header's. Set these to the same glyphs the theme gives --vn-grid-sort-icon-asc / -desc, and -icon-size to the font-size it gives .vn-grid-sort-indicator — same mark, same size, so a chip and a sorted column state direction in one language. Every built-in does both: Carbon's / at its 14px, Fiori's, Fluent's, Glow's and the default theme's / at their 12px/12px/12px/11px, the sample's vn-grid-apple.css its / at 20px, and vn-grid-material.css its 16×20 url() SVG of a triangle over an "AZ" — the one theme whose mark is an image rather than a character. A url() glyph needs no size to be matched, because a replaced element renders at the SVG's own intrinsic size and no font-size can scale it: painting the header's URL is painting it at the header's size. Its -icon-size is still set (to the 20px box height .vn-grid-sort-indicator carries in that theme), because the -offset-y below is an em and needs a basis.

The glyph and the size are both per-theme decisions rather than automatic inheritance, for the same reason: --vn-grid-sort-icon-* is declared on .vn-grid-header-table th, which the group bar is not inside, and the indicator's size is a font-size in a rule rather than a token, so nothing on the chip can cascade from either.

Note that aria-pressed="true" on the direction button means descending (it reports "not ascending"), which is why -icon-desc is the [aria-pressed="true"] rule.

-offset-y is optical centring, and it has to be measured. align-items: center centres the glyph's box; it cannot centre the glyph's ink inside it. A text glyph sits on a baseline placed from the font's ascent/descent, and the em box carries descender space that an arrow never uses, so the ink lands high — by an amount that depends entirely on the font. Measured before correction, the shipped themes' chip arrows sat between 0.1px and 2.9px high (vn-grid-apple.css's the worst), which is plainly visible in a 20px button.

No CSS available today centres ink font-independently (text-box-trim is too new to rely on), so the base owns the slot and each theme supplies a value measured for its own font and glyph; the value shifts the glyph down in em, so it tracks -icon-size.

Measure on a fine raster, then check the half-pixel boundary. At devicePixelRatio: 1 a glyph's vertical position is snapped to a whole device pixel, so a correction under half a pixel is dropped there and one over it is applied as a full pixel — on a glyph the rasterizer had already snapped into place. The value that measures best at 8x can therefore be the worst one to ship: a correction of just over half a pixel buys nothing at 1x and costs a whole pixel. vn-grid-material.css's chip, back when it was a typed , sat just past it at 0.105em (≈ 0.89px) and rendered 1px low on an ordinary monitor while measuring dead centre on a HiDPI one; at 0.04em it rendered centred at 1x and stayed well inside tolerance at 8x. Land on the smaller side of the boundary unless the raw ink is genuinely more than a pixel out — then aim for a value near a whole multiple of a pixel instead, which is what vn-grid-apple.css's 0.27em (≈5.4px against its 20px glyph box — the largest value that still snaps to a clean 5px at 1x, while 8x gets the full 5.4px) does for a that sits high in its em box, and vn-grid-carbon.css's -0.14em (≈-1.96px, rounding to a clean -2px) does for an its own -icon-size left 1.94px low. What is left over at 1x is up to half a pixel, which is as close as a typed glyph gets: Carbon's lands on a half-pixel raw, so no whole-pixel shift can better it.

-icon-size is spelled as a length, not an em. The chip's text is smaller than the header's in every built-in theme — Fiori's 0.8125rem chip against its 12px indicator, Carbon's 0.75rem chip against its 14px one — so an em of the chip cannot express the header's size at all, which is why the shipped value is a px/rem length that simply restates what .vn-grid-sort-indicator is set to. It is also why the base's 1em fallback is a neutral, not a target: a theme that leaves it renders an arrow sized to the chip's label, which is the undersized mark this token exists to correct.

A glyph's rendered ink is not a fixed fraction of its em box, and that is what makes the parity worth stating in a length rather than eyeballing. / (U+2303/U+2304) are drawn small in every font but SF Pro, so at 1em the sample's Apple chip showed 3.4px of arrow next to a 6.4px remove ✕; at the header's 20px it shows ~5px, exactly what its own sorted column shows. A glyph taller than its button is not a concern: the base clips it to the button (see the note above). Re-measure the offsets whenever -icon-size changes — they are in em of the glyph, so they scale with it, but they scale the correction and not the font's own error, and the two rarely move together.

The button clips the glyph, and that is load-bearing. A -icon-size above 1em makes the pseudo-element's box taller than the button, and -offset-y then translates it further down; a transformed box counts as scrollable overflow of whatever scroll container it sits in, and the group bar is one (overflow-x: auto computes overflow-y to auto). Two pixels of that are invisible while the bar sits still and break the first chip drag: the browser auto-scrolls the drop container, and the bar's contents stay that far above centre for the rest of the session, with nothing to scroll them back. So .vn-grid-group-chip-direction is overflow: hidden in the base — a theme's oversized glyph is clipped to its own hit-area, which costs nothing because -offset-y centres the ink well inside the box, and clips no focus ring (an outline paints outside the border box). grouping-bar-theming.spec.js asserts the bar's scrollHeight equals its clientHeight in every theme, idle and with a drop marker up, so a theme that finds a new way to overflow it fails there rather than in a user's drag.

Measure the descending state too. -icon-asc and -icon-desc are different marks, and nothing guarantees the second sits where the first does. The sample's vn-grid-apple.css sets (U+2303) and (U+2304), and the fallback font places 3.4px lower in its em box — so one shared correction centred the ascending chip perfectly and left the descending one 6.25px below centre in a 20px button, on every raster. vn-grid-material.css has the same split for a different reason: its pair is two SVGs rather than one mirrored, and the descending one puts its triangle below the "AZ" rather than above it, which leaves its ink 1.9px low where the ascending one sits 3.4px high. --vn-grid-group-chip-icon-desc-offset-y exists for exactly that case and falls back to -icon-offset-y, so a theme whose pair really is symmetric (/, /) never sets it.

grouping-bar-theming.spec.js re-measures the rendered pixels for every theme at both ratios and both direction states, and fails if one drifts off centre — run it after changing a glyph, a font stack, or a button size. It reads per-row ink coverage rather than thresholding luminance, because a thin stem antialiases to a grey no threshold catches at 1x, and it centres the ink's extent rather than its centre of mass, because a triangle's mass sits a third of the way up from its base. The 8x tolerance is 1px, deliberately looser than the fraction of a pixel the shipped themes achieve, because the corrections are measured against the fonts the test environment resolves and a machine with the theme's real font installed will land somewhat differently; the 1x tolerance is 0.75px, tight enough to catch a correction that crossed the boundary and loose enough for the half pixel that is irreducible once it has not.

Why the remove ✕ is drawn instead. The correction above is a sub-pixel shift, and at devicePixelRatio: 1 — the raster most people look at — a glyph's vertical position is snapped to a whole device pixel. A nudge under half a pixel is dropped there, and one over it lands as a full pixel on a glyph the rasterizer had already snapped into place, so no single measured value is right at both 1x and 2x: a × typed as content was landing about a pixel low at 1x in every theme while measuring dead centre on a HiDPI raster. line-height cannot help either — the ink-to-box offset works out independent of it, so only a translate can move the ink, and translates quantize. Two crossed hairlines are centred by geometry, identically at every ratio and in every font, which is why the affordance stopped being a glyph. grouping-bar-theming.spec.js measures it at 1x as well as 8x, to a tighter tolerance than the typed direction glyph, because being exact at 1x is the whole point.

Focus ring

Token Falls back to
--vn-grid-group-focus-color --vn-grid-filter-accent, then #1976d2
--vn-grid-group-focus-width 2px
--vn-grid-group-focus-offset 1px (2px on the caption toggle)

Shared by the caption toggle and both chip buttons, so a theme that already sets --vn-grid-filter-accent gets a consistent focus treatment for free.

4.9 Search-Match Highlighting Tokens

Base rule (.vn-grid-body-table td mark.vn-grid-search-match, vanilla-grid.css) for highlightSearchMatches (see the virtualization doc, §1.17). Six independent tokens, each with a neutral/no-op default, so a theme that sets none of them still gets a sane flat-yellow highlight, and a theme that sets some picks which visual aspects it wants an opinion on rather than being forced into "background fill" as the only vocabulary:

Token Falls back to Lets a theme express
--vn-grid-search-match-bg #fff3a0 flat highlight fill (the conventional look)
--vn-grid-search-match-color inherit text recolor, e.g. to an accent instead of/with a fill
--vn-grid-search-match-weight inherit bolding instead of (or alongside) color
--vn-grid-search-match-radius 2px sharp corners for a squared theme vs rounded
--vn-grid-search-match-shadow none a glow/outline effect (pair with -bg: transparent)
--vn-grid-search-match-decoration none an underline-based treatment instead of a fill

These are meant to be mixed, not just recolored. vn-grid-default.css, vn-grid-material.css, and vn-grid-fluent.css set only -bg/-color for a conventional flat-fill look; vn-grid-fiori.css, vn-grid-carbon.css, and vn-grid-carbon-dark.css additionally set -radius: 0 to match those themes' squared-off chip/badge geometry elsewhere in the grid (§4.8); vn-grid-glow.css and vn-grid-glow-dark.css set -bg: transparent with -shadow and -weight instead, producing an actual glow around the matched text rather than a filled box — on-brand for the theme's name. TEMPLATE-vn-grid-theme.css documents all six as commented placeholders for a new theme author to pick from.


5. Built-in Themes: Implementation Notes

5.1 vn-grid-default.css

5.2 vn-grid-carbon.css

5.3 vn-grid-carbon-dark.css

5.4 vn-grid-fiori.css


6. Runtime Theme Switching

The <vn-grid> element manages its own CSS loading. When it connects to the DOM, it automatically injects <link> tags for the base structural stylesheet (vanilla-grid.css) and the active theme stylesheet into <head>.

Two attributes control the theme:

Attribute Default Description
theme 'default' One of the built-in theme names: default, material, fiori, carbon, carbon-dark, glow, glow-dark, fluent
theme-css-path Path to a custom CSS file. When set, overrides the theme attribute

Declarative usage:

<vn-grid id="myGrid" theme="carbon-dark">
    <vn-grid-column field="Name" header="Name" type="string"></vn-grid-column>
</vn-grid>

Runtime switching:

// Via setTheme() method
document.getElementById('myGrid').setTheme('material');

// Via property
document.getElementById('myGrid').theme = 'fiori';

// Custom CSS path (overrides theme attribute)
document.getElementById('myGrid').themeCssPath = 'my-themes/grid-custom.css';

// Enumerate built-in + registered themes
console.log(VanillaGridElement.getSupportedThemes());
// → ['default', 'material', 'fiori', 'carbon', 'carbon-dark', 'glow', 'glow-dark', 'fluent']
// (legacy alias: VanillaGridElement.SUPPORTED_THEMES)

6.1.1 Registering custom named themes

A host can publish its own named themes once at startup so the theme attribute (and the theme dropdown) accepts them like built-ins:

VanillaGridElement.registerTheme('corporate', '/themes/grid-corporate.css');
VanillaGridElement.registerTheme('high-contrast', '/themes/grid-hc.css');

document.querySelector('vn-grid').setTheme('corporate');
console.log(VanillaGridElement.getSupportedThemes());
// → ['default', 'material', 'fiori', 'carbon', 'carbon-dark', 'glow', 'glow-dark', 'fluent', 'corporate', 'high-contrast']

Use this instead of the theme-css-path escape hatch when the same theme is used across many grids: the registry centralises the path and keeps the theme attribute declarative.

The component appends a ?v=<token> cache-bust marker to every theme stylesheet URL (and to the base vanilla-grid.css and the auto-loaded feature scripts). The token resolution is shared across all loaded assets:

See 20-cache-bust-implementation.md for the full strategy.

6.1.2 Multiple <vn-grid> instances with different themes

Each <vn-grid> injects its theme <link> under an id derived from the resolved stylesheet URL (theme name / registered theme / theme-css-path), not a single fixed id. Instances that resolve to the same stylesheet share one <link> (no duplicate downloads); instances that resolve to different stylesheets each get their own, so two grids with different theme attributes on the same page render independently instead of the last one to connect/switch winning for both:

<vn-grid id="gridA" theme="carbon-dark">…</vn-grid>
<vn-grid id="gridB" theme="fiori">…</vn-grid>
<!-- gridA and gridB each keep their own theme <link>, both stay correctly themed -->

A per-id refcount tracks how many live instances currently point at a given link; it's released (and the <link> removed from <head>) when the last instance referencing it switches theme or disconnects, so links don't accumulate as grids are added/removed/re-themed. The structural base stylesheet (vanilla-grid.css, id vn-grid-base-css) is unaffected — it's the same file for every instance regardless of theme, so it stays a single shared <link>.

Every instance pointing at a shared link — including one whose theme attribute already resolved to that link before it finished loading — gets its own callback once the stylesheet is ready, so each independently re-reads --vn-grid-row-height / --vn-grid-header-height (§4.3, §4.4) from the now-loaded CSS. This holds regardless of how many <vn-grid> instances on the page share the same theme.

The base stylesheet is tracked the same way (a single module-level "loaded" flag plus a waiter set, since there is only ever one such link), and each instance's callback runs only once both its theme sheet and the base sheet have loaded. The base sheet is requested first but is several times the size of any theme file, so it regularly resolves second; measuring a row in that window yields an inflated height that then latches (§4.3). A base stylesheet that fails to load resolves its waiters too, so a missing file degrades rather than deadlocking every instance.

<vn-grid-toolbar> applies the identical strategy for its own theme <link> (see vanilla-grid-toolbar/README.md).

6.2 Manual (without Web Component)

When using VanillaGrid directly (without <vn-grid>), manage theme stylesheets manually:

<link rel="stylesheet" href="../vanilla-grid/vanilla-grid.css">
<link id="gridThemeLink" rel="stylesheet" href="../vanilla-grid/themes/vn-grid-default.css">
document.getElementById('gridThemeLink').href =
  '../vanilla-grid/themes/vn-grid-carbon-dark.css';

7. What Not to Change in a Theme

To avoid breaking interactions and performance, a theme should not alter these structural behaviors from base CSS:

Changing these often causes drag/reorder or freeze/scroll artifacts.

The rule also runs the other way for grouping: do not add grouping paint to vanilla-grid.css. Every colour, radius, glyph, font and spacing value there is a var() with a neutral fallback precisely so the themes can disagree; a literal value added to the base silently overrides nobody's theme today and becomes an inconsistency to unpick tomorrow.


  1. Copy themes/TEMPLATE-vn-grid-theme.css to themes/vn-grid-yourtheme.css
  2. Replace placeholder tokens with your design-system values
  3. Keep layout/positioning in base CSS untouched
  4. Validate all interactive states:
    • header hover and sort
    • row hover and selection
    • column resize handle visibility
    • frozen columns + freeze guide line
    • context menu contrast and disabled states
    • filter panel (Apply/Clear buttons) and the header funnel icon (resting, hover, active)
    • skeleton loading readability
    • empty state ("no rows") — no row-hover tint on the message row, no leftover placeholder-stripe backdrop below it
    • row grouping (§ 4.8) — group by two columns and check the bar, its chips, both chip buttons' hover and focus rings, and the caption rows at more than one nesting level
  5. Test both wide and narrow viewport widths

9. Verification Checklist

Before shipping a theme, verify:

If all checks pass, the theme is visually compatible with Vanilla-Grid core behavior.