Theming

Theming Overview

Three cooperating layers: the prebuilt themes, the override CSS, and the density toggle.

Pretable's theming is built around three cooperating layers. Each owns one thing.

Composition order

Independent dark-mode and accent-override toggles on the same wrapper prove the layer order: the override wins in both light and dark because it resolves after the theme, not because of where it sits relative to the dark block.

.md

Both buttons above touch the same wrapper, and both write tokens that the theme also writes. Neither wins by being more specific — the reading below is what actually decides it.

The three layers

┌─────────────────────────────────────┐
│  Your CSS                           │   Layer 3 (consumer override)
│  :root { --pretable-accent: red; }  │   Wins on source order (loaded last)
└─────────────────────────────────────┘
                 ▼ overrides
┌─────────────────────────────────────┐
│  @pretable/ui/grid.css              │   Layer 2 (chrome)
│  [data-pretable-cell] {             │   Targets engine attributes
│    background: var(--pretable-...); │
│    ...                              │
│  }                                  │
└─────────────────────────────────────┘
                 ▼ resolves vars from
┌─────────────────────────────────────┐
│  @pretable/ui/themes/pretable.css   │   Layer 1 (theme tokens)
│  :root {                            │
│    --pretable-bg-grid: #ffffff;     │
│    --pretable-accent: #3f5a7a;      │   50 tokens defined
│    ...                              │
│  }                                  │
└─────────────────────────────────────┘
                 ▼ writes to
┌─────────────────────────────────────┐
│  @pretable/react                    │   Engine (no styling)
│  <div data-pretable-cell="" />      │
│  Reads --pretable-row-height +      │
│  --pretable-header-height for       │
│  virtualization math.               │
└─────────────────────────────────────┘

Layer 1 — theme tokens. A theme file declares all 50 --pretable-* tokens at :root. This is the single source of truth for what the grid looks like. Three ship: themes/pretable.css is the house theme and the default, and themes/excel.css and themes/material.css are compatibility skins for apps whose design language is already one of those. See Pick a theme.

Layer 2 — grid.css. A selector-based stylesheet (grid.css) targets the engine's data attributes ([data-pretable-scroll-viewport], [data-pretable-cell], etc.) and applies var(--pretable-*) references. The engine emits the markup; this layer makes it visible.

Layer 3 — your CSS. Whatever you put in your application's stylesheet, loaded after the imports. A redefinition at :root in your stylesheet carries exactly the same specificity as the theme's :root, so what decides it is source order: yours is read later, so yours wins. The override story.

All grid selectors ship inside a single @layer pretable cascade layer and are wrapped in :where() (zero specificity), so your overrides win without specificity tricks. See Cascade & overrides for the full contract and the layer-order line for Tailwind/reset users.

The 50-token contract

Pretable's public token contract has 50 tokens, grouped:

  • Surfaces (7): bg-grid, bg-grid-alt, bg-pinned, bg-group-row, bg-header, bg-toolbar, bg-tooltip
  • Text (3): text-cell, text-header, text-dim
  • Lines and radii (6): rule, rule-vertical, rule-width, rule-strong, radius, radius-control
  • State (4): bg-hover, bg-selected, text-selected, focus-ring
  • Editing (2): edit-bg, text-error
  • Accent (1): accent
  • Semantic ramp (4): positive, negative, warning, info
  • Elevation (3): shadow-overlay, shadow-card, seam-color
  • Density (8): row-height, header-height, group-panel-height, cell-padding-x, cell-padding-y, group-indent, font-size-cell, font-size-header
  • Icons (1): icon-size
  • Typography (2): font-sans, font-mono
  • Grid controls (9): selection-bg, checkbox-bg, checkbox-border, checkbox-checked-bg, checkbox-checked-fg, resize-handle, resize-handle-hover, reorder-ghost-bg, reorder-drop-indicator

Each prefixed --pretable-. See Token reference for descriptions, types, and example values per theme.

Three of the density tokens (--pretable-row-height, --pretable-header-height, and --pretable-group-panel-height) are read by the engine in JavaScript, because it needs them as numbers to lay out rows, the sticky header, and the group panel. The other 47 are CSS-only. The three are resolved against the grid's own element, so they inherit down from wherever the attribute is set, the same as the CSS-only ones. @pretable/ui's getDensityHeights reads the first two of them, and takes the element to resolve against.

The two attribute axes

Two data-* attributes toggle runtime variants. <html> is the usual place for both; either also works on a wrapper element, scoping the variant to that subtree.

  • data-theme="dark" — activates the [data-theme="dark"] block in the active theme file. pretable and Material both ship one; Excel is light-only, so the attribute has nothing to match and the grid stays light.
  • data-density="compact|standard|spacious" — switches density tokens. Each theme defines its own three tiers; the engine picks up the change via a MutationObserver on the grid element and its ancestors, re-reads the new heights, and re-renders.

The two axes compose independently. <html data-theme="dark" data-density="compact"> gives you the active theme's dark colors at its compact dimensions. Nothing has to be resolved between them: in every theme that ships both, the two blocks write disjoint sets of tokens, so neither can win or lose against the other. A dark block restates appearance — the color tokens, plus the elevation tokens, whose values are colors too (pretable restates all three of --pretable-shadow-overlay, --pretable-shadow-card and --pretable-seam-color; Material restates the first), plus — in pretable alone — the color-scheme property that steers form controls and scrollbars. The density blocks restate dimensions. No token is written by both.

Composition order

When the cascade resolves, layers compose in this order:

  1. Theme file's :root block writes initial token values.
  2. Theme file's [data-density] block (if attribute is set) overrides density tokens.
  3. Theme file's [data-theme="dark"] block (pretable and Material, if the attribute is set) overrides color tokens.
  4. Your application's :root block (if you redefine any tokens) wins — not on specificity, which is identical, but because it is read after the theme.
  5. grid.css reads var(--pretable-*) references and applies them to the engine's data-attribute selectors.

You can mix any of these — the demo at the top of this page is exactly this: the default theme, in dark mode, with the accent color overridden. Here's the same thing as static CSS and HTML rather than a wrapper div and a button:

css
@import "@pretable/ui/themes/pretable.css";
@import "@pretable/ui/grid.css";
 
:root {
  --pretable-accent: #ff5722;
}
html
<html data-theme="dark">
  ...
</html>

The accent override applies in both light and dark mode — the demo's caption shows this directly: toggle dark mode with the override on, and the resolved value doesn't move. That's not because the override sits outside the [data-theme="dark"] block. :root is a pseudo-class and [data-theme="dark"] is an attribute selector, and both weigh the same: specificity (0,1,0). Where a rule sits relative to that block decides nothing on its own.

What decides it is step 4 above. Your stylesheet is read after the theme, so on a tie your :root beats the theme's dark block just as it beats the theme's light one, and for the tokens you redefined the theme's dark values stop having any effect. Reverse the load order — let the theme come after your stylesheet in your bundler entry — and the very same rule loses in both modes. To vary a token per mode, write your own [data-theme="dark"] block after the theme import — see Override tokens.

Where to go next