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:
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
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:
- Table layout primitives (
.vn-grid-table-container,.vn-grid-header-spacer,.vn-grid-header-table,.vn-grid-body-table) - Header composition (
.vn-grid-header-label,.vn-grid-header-text-wrapper,.vn-grid-header-secondary-text) - Resizer geometry (
.vn-grid-col-resizer,.vn-grid-col-resizer::after,.vn-grid-resize-guide) - Sorting marker structure (
.vn-grid-sort-indicator,th.vn-grid-sorted.*) — the base owns the mechanism only; the glyphs themselves are required per-theme tokens (§4.5.1) - Context menu shell (
.vn-grid-header-context-menu,.vn-grid-header-context-menu-item) - Frozen-column mechanics (
.vn-grid-frozen-col,.vn-grid-frozen-col-last,.vn-grid-freeze-guide)
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 … (Active → A… → Acti → Act). 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:
- Set hover and sorted background colors only via
th[style*="cursor: pointer"]:hover::beforeandth.vn-grid-sorted::before— never directly onth. - The base CSS includes a safety-net rule (
.vn-grid-header-table th[style*="cursor: pointer"]:hover { background-color: transparent }at specificity 0-3-1) that overrides any legacy theme rule that incorrectly sets background-color onthdirectly. - Do not change
right,bottom,z-index,position, orcontentonth::beforeorth.vn-grid-sorted::beforein theme files.
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:
.vn-grid-table-container.vn-grid-header-spacer.vn-grid-header-table thead,.vn-grid-header-table th.vn-grid-header-secondary-text.vn-grid-col-resizer,.vn-grid-col-resizer::after.vn-grid-sort-indicator+th.vn-grid-sorted*.vn-grid-virtual-list-viewport,.vn-grid-body-table, row/cell selectors.vn-grid-empty-message.vn-grid-skeleton-row,.vn-grid-skeleton-shimmer.vn-grid-table-container, .vn-grid-header-context-menu(via CSS vars — dual selector required, see §4.1).vn-grid-table-containerfrozen-column custom properties- Column filter panel —
.vn-grid-header-context-menu.vn-grid-filter-panel(via--vn-grid-filter-*vars; defaults inherit the menu look) - Header filter funnel icon —
.vn-grid-table-container(via--vn-grid-filter-icon-*vars) - 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:
--vn-grid-menu-bg--vn-grid-menu-color--vn-grid-menu-border--vn-grid-menu-shadow--vn-grid-menu-radius--vn-grid-menu-control-radius— corner radius of inner controls (menu items, filter inputs, Apply/Clear). Defaults to--vn-grid-menu-radius, so squared themes (radius0) get squared controls automatically.--vn-grid-menu-font-size--vn-grid-menu-font-family— important: the menu and filter panel are appended todocument.body, so they do not inherit the grid's font. Set this to the same stack used for.vn-grid-header-table thso the menu, filter panel, and header text stay consistent. Defaults toinherit(host page font), which is correct only when the theme sets no explicit header font.--vn-grid-menu-item-hover-bg--vn-grid-menu-item-disabled-opacity--vn-grid-menu-color-scheme(defaultlight) — set todarkin dark themes. The date/time filter inputs are native<input type="date|time|datetime-local">, whose picker popup is rendered by the browser and is not CSS-styleable. The only lever is the CSScolor-schemeproperty:darkmakes the browser draw the native calendar popup and its picker icon in dark mode, keeping them coherent with a dark theme. Applied to the menu/panel so it cascades to those inputs.--vn-grid-menu-calendar-icon-opacity(default0.85) — opacity of the native calendar/clock picker icon (::-webkit-calendar-picker-indicator) in filter inputs; dark themes set1so it stays clearly visible.
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-menuand 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, keepvanilla-grid.cssbefore the theme file.
Menu/filter panel theme scoping
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:
--vn-grid-frozen-bg— base frozen cell background (must be opaque)--vn-grid-frozen-bg-alt— optional, matches the theme'stbody tr:nth-child(even)zebra-stripe color. Required only when the theme paints alternating row backgrounds — otherwise the frozen column's opaque--vn-grid-frozen-bgwill visibly cover the stripe. Falls back to--vn-grid-frozen-bg.--vn-grid-frozen-bg-hover— hover color for frozen cells--vn-grid-frozen-bg-selected— selection tint layered over the frozen background. The base stylesheet also applies this same token to the selected row's non-frozen cells, so a selected row shares one background across the freeze boundary (frozen-bg and body-bg are equal in every theme, so the same tint resolves identically on both sides). The selected-row rule carries a:nth-child(even)variant to outrank the theme's zebra-stripe rule on even rows.--vn-grid-frozen-border-color— freeze boundary line color. It paints two marks: the full-height.vn-grid-freeze-guideoverlay, which runs over--vn-grid-frozen-bg, and the resize indicator onth.vn-grid-frozen-col-last, which runs over--vn-grid-frozen-header-bg. A theme with a dark or saturated header must therefore pick a colour that reads on both surfaces, or the header half of the boundary disappears — the default theme's original#3b82f6line had an exact luminance match with its#667eeaheader (1.00:1) and is now#312e81.tests/node/freeze-guide-contrast.test.jsstatically asserts at least 3.0:1 (WCAG 2.1 SC 1.4.11, non-text contrast) against both backgrounds, for every shipped theme and for hand-authoredsamples/<app>/themes/grid themes.--vn-grid-frozen-header-bg— frozen header cell background
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:
- If the host passes
layout.rowHeighttoinitializeGrid()or sets therow-heightattribute on<vn-grid>, that value wins and is written back to the container as an inline--vn-grid-row-heightoverride. - Otherwise the grid reads the active theme's
--vn-grid-row-heightand uses it as both the virtualization row height and the CSS value (so checkbox sizing, last-row clamps, etc. stay in sync). - 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):
- If the host passes
layout.headerHeighttoinitializeGrid()or sets theheader-heightattribute on<vn-grid>, that value wins and is written back to the container as an inline--vn-grid-header-heightoverride. - 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. - If no theme declares the variable, the base stylesheet's
32pxdefault 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:
--vn-grid-drop-tip-width--vn-grid-drop-tip-height--vn-grid-drop-tip-color--vn-grid-drop-tip-edge-inset--vn-grid-drop-tip-y-adjust
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).
--vn-grid-sort-icon-asc—contentfor the ascending glyph--vn-grid-sort-icon-desc—contentfor the descending glyph--vn-grid-sort-icon-sortable—contentfor the unsorted "sortable" affordance shown on sortable-but-unsorted columns;none= no affordance--vn-grid-sort-icon-sortable-opacity— the affordance's resting opacity--vn-grid-sort-icon-sortable-hover-opacity— its opacity while the header is hovered
Two further tokens are optional — both default to 0, so a theme that never
mentions them renders exactly as it always has:
--vn-grid-sort-icon-offset-y— optical centring for the direction glyph--vn-grid-sort-icon-desc-offset-y— the same for the descending glyph, falling back to-offset-y
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-text — not .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:
--vn-grid-filter-accent— primary/brand color driving the Apply button--vn-grid-filter-apply-color— text/icon color on the Apply button--vn-grid-filter-actions-justify—justify-contentfor the Clear/Apply row. Defaults toflex-end(both buttons grouped at the trailing edge — Carbon's own convention;vn-grid-carbon/vn-grid-carbon-darkdon't override it). Every other built-in theme (default,material,fiori,glow,glow-dark,fluent) sets it tospace-between, pinning Clear to the leading edge and Apply to the trailing edge instead — Clear is appended before Apply inheader-menu.feature.js, so DOM order determines which button lands on which side.
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:
--vn-grid-filter-panel-bg←--vn-grid-menu-bg--vn-grid-filter-panel-color←--vn-grid-menu-color--vn-grid-filter-panel-border←--vn-grid-menu-border--vn-grid-filter-input-bg←--vn-grid-menu-bg(operator/value fields)--vn-grid-filter-input-border←--vn-grid-menu-border--vn-grid-filter-apply-bg←--vn-grid-filter-accent--vn-grid-filter-apply-hover-bg←color-mix(in srgb, var(--vn-grid-filter-apply-bg) 88%, black 12%)— darkens the button's own background rather than reusing--vn-grid-menu-item-hover-bg(that token is a faint tint sized forcolor: inheritmenu items; against the Apply button's fixed--vn-grid-filter-apply-colorit could land anywhere from washed-out to an exact match). Themes whose accent is lighter than their fixed Apply text color (light accent + dark text, e.g.vn-grid-carbon-dark,vn-grid-glow-dark) override this to mix towardwhiteinstead, so darkening doesn't close the gap with an already-dark label.--vn-grid-filter-clear-hover-bg←--vn-grid-menu-item-hover-bg
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:
--vn-grid-filter-icon-color(defaultcurrentColor) — outline funnel color--vn-grid-filter-icon-active-color(defaultcurrentColor) — filled funnel when a filter is active; most themes set this to the brand accent--vn-grid-filter-icon-opacity(default0.45) — resting opacity--vn-grid-filter-icon-opacity-header-hover(default0.7) — when the header cell is hovered--vn-grid-filter-icon-opacity-hover(default1) — when the icon itself is hovered (also used for the active state)--vn-grid-filter-icon— the resting (outline) funnel shape, a masked SVGurl(...). Override per theme to use a different icon entirely.--vn-grid-filter-icon-active— the active (filled) funnel shape, swapped in when a filter is applied.
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.
Footer rows
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 |
x̄ |
--vn-grid-aggregate-icon-median |
x̃ |
--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-width → 12px |
--vn-grid-group-chip-drop-height |
--vn-grid-drop-tip-height → 6px |
--vn-grid-group-chip-drop-color |
--vn-grid-drop-tip-color → color-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
- Gradient header (
#667eea → #764ba2) - Rounded container corners (
8px) - Soft shadows and lighter hover treatment
- Frozen tokens aligned with purple-blue brand color
- Group caption toggle: filled triangles (▼ expanded / ▶ collapsed) instead of the base chevrons
5.2 vn-grid-carbon.css
- IBM Carbon light palette (gray-20
#e0e0e0column header with gray-100#161616text,#0f62feaccents) - Sharp corners (
border-radius: 0) - IBM Plex Sans typography
- No zebra striping, hover-only row emphasis
- Group caption toggle: a boxed plus (collapsed) / boxed minus (expanded) at
1.2eminstead of the default chevrons
5.3 vn-grid-carbon-dark.css
- Dark Carbon palette (
#161616body,#262626header) - Light-on-dark contrast with
#78a9ffaccent - Same geometric language as Carbon light, including the boxed plus/minus group toggle
5.4 vn-grid-fiori.css
- SAP Fiori-inspired neutrals with
#0a6ed1accent - Enterprise-style subtle borders/shadows
- Includes alternating row backgrounds
6. Runtime Theme Switching
6.1 Via the <vn-grid> Web Component (Recommended)
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:
- Production builds — the build replaces
__VANILLA_COMPONENTS_VERSION__with thepackage.jsonversion, so the token is stable across deploys and CDN/browser caches keep working until a new version ships. - Unbuilt sources / development — the placeholder is undeclared and the runtime falls back to
Date.now(), so iterative editing always reloads CSS and JS without a hard-refresh.
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:
- Sticky/frozen positioning (
position: sticky/ header pinning model) - Scrollbar wrapper/track geometry
- Resizer absolute positioning/hit area dimensions
- Virtualization table flow and overflow model
- Z-index layering for resize/freeze guides
- Background-effects slot geometry (
th::before/th.vn-grid-sorted::before— themes must only setbackground-color; do not overrideright,bottom,z-index,position, orcontent) - Group bar and chip mechanics — the bar's
flex: 0 0 auto/display: flex/[hidden]rules, the chip'sinline-flex, and the chip buttons' centred hit-area. Their paint is entirely yours (§ 4.8); their flow is not, and a bar that stops beingflex: 0 0 autore-sizes the virtual row pool incorrectly
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.
8. Recommended Workflow for a New Theme
- Copy
themes/TEMPLATE-vn-grid-theme.csstothemes/vn-grid-yourtheme.css - Replace placeholder tokens with your design-system values
- Keep layout/positioning in base CSS untouched
- 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
- Test both wide and narrow viewport widths
9. Verification Checklist
Before shipping a theme, verify:
- Text contrast meets accessibility targets in header, body, and menu
- Frozen cells remain opaque during horizontal scroll
- Sort indicators are visible on all header backgrounds
- Resize handles are visible but not visually dominant
- Context menu colors, border, and hover states are coherent
- Filter panel Apply button uses a readable accent; the active funnel icon is visible against the header
- Skeleton shimmer remains distinguishable from row background
- Group bar chips read as raised on the strip, their direction/remove glyphs are legible at the theme's chip button size, and the caption rows are distinguishable from data rows
If all checks pass, the theme is visually compatible with Vanilla-Grid core behavior.