Theming Custom themes

Custom themes

Author a theme file by starting from a shipped one, when overrides grow beyond a handful of tokens.

When overrides grow beyond a handful of tokens, switch from override-on-top-of-existing to authoring a theme file from scratch. The contract is straightforward: define all 50 --pretable-* tokens at :root, and optionally add [data-density="..."] and [data-theme="dark"] blocks for runtime variants. Here is one, complete and running:

A complete custom theme

brand.css defines all 50 pretable tokens plus a dark block and density tiers, scoped to a wrapper class so this one demo can run its own theme alongside the rest of the docs site.

.md

brand.css above is that contract met in full: every one of the 50 tokens, a [data-theme="dark"] block, and both non-default density tiers. Its selectors are scoped to a wrapper class rather than written at a bare :root, purely so this one demo doesn't re-theme the rest of this documentation page around it; the callout beneath the code explains the substitution. Every focus-marked line in it is one this page walks through by name in the sections that follow, so it is the file to copy as your starting point.

Start from a template

brand.css began as a shipped theme. Copy one out of node_modules/@pretable/ui/themes/ into your project, rename it (say, themes/brand.css), and edit the values. They are hand-readable CSS — no preprocessing, no build step needed.

Start from pretable.css unless you have a reason not to. It is the most complete of the three: it declares all 50 tokens as literals, ships a full [data-theme="dark"] block, and defines all three density tiers, so nothing is missing when you begin editing. Its comments also explain why each value is what it is, which is the part you most want when you change one. Start from excel.css or material.css instead when your brand theme is a variation on one of those looks rather than a departure from it.

The excerpt below shows the same shape as the example's brand.css in miniature: the surface, text, line, state, accent, density, and typography groups, with the editing, semantic-ramp, elevation, icon, and grid-control groups left out for length. The token reference has all 50 with descriptions and types.

css
/* themes/brand.css */
:root {
  /* Surfaces */
  --pretable-bg-grid: #ffffff;
  --pretable-bg-grid-alt: #fafafa;
  --pretable-bg-pinned: #ffffff;
  --pretable-bg-group-row: #f7f7f7;
  --pretable-bg-header: #f0f0f0;
  --pretable-bg-toolbar: #f0f0f0;
  --pretable-bg-tooltip: #ffffff;
 
  /* Text */
  --pretable-text-cell: #1a1a1a;
  --pretable-text-header: #404040;
  --pretable-text-dim: #6a6a6a;
 
  /* Lines and radii */
  --pretable-rule: #d8d8d8;
  --pretable-rule-vertical: transparent;
  --pretable-rule-width: 1px;
  --pretable-rule-strong: #a0a0a0;
  --pretable-radius: 4px;
  --pretable-radius-control: 4px;
 
  /* State */
  --pretable-bg-hover: #f5f5f5;
  --pretable-bg-selected: rgba(255, 87, 34, 0.1);
  --pretable-text-selected: #1a1a1a;
  --pretable-focus-ring: #ff5722;
 
  /* Accent */
  --pretable-accent: #ff5722;
 
  /* Density */
  --pretable-row-height: 28px;
  --pretable-header-height: 32px;
  --pretable-group-panel-height: 32px;
  --pretable-cell-padding-x: 8px;
  --pretable-cell-padding-y: 4px;
  --pretable-group-indent: 16px;
  --pretable-font-size-cell: 14px;
  --pretable-font-size-header: 12px;
 
  /* Typography */
  --pretable-font-sans:
    "Inter", system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
  --pretable-font-mono: "JetBrains Mono", ui-monospace, Consolas, monospace;
}

Import your theme file in place of the shipped one. grid.css still comes from the package — it is the chrome layer that reads your tokens, and it is not something a custom theme replaces:

css
@import "./themes/brand.css";
@import "@pretable/ui/grid.css";

Add a dark-mode variant

If your theme should have dark mode, add a [data-theme="dark"] block. Override only the color tokens (density and typography typically inherit from light):

css
[data-theme="dark"] {
  /* Surfaces */
  --pretable-bg-grid: #1a1a1a;
  --pretable-bg-grid-alt: #1f1f1f;
  --pretable-bg-header: #2a2a2a;
  --pretable-bg-toolbar: #2a2a2a;
  --pretable-bg-tooltip: #2a2a2a;
 
  /* Text */
  --pretable-text-cell: #e8e8e8;
  --pretable-text-header: #c0c0c0;
  --pretable-text-dim: #888888;
 
  /* Lines */
  --pretable-rule: #3a3a3a;
  --pretable-rule-strong: #585858;
 
  /* State */
  --pretable-bg-hover: #252525;
  --pretable-bg-selected: rgba(255, 138, 101, 0.15);
  --pretable-text-selected: #ffffff;
  --pretable-focus-ring: #ff8a65;
 
  /* Accent */
  --pretable-accent: #ff8a65;
}

Toggle data-theme="dark" on <html> to activate. See Light / dark switching for the React wiring.

That block is an excerpt, and the tokens it leaves out are the ones that go wrong. Restate every color token your theme declares, not just the obvious surfaces: the editor surface, the error color, the semantic ramp, the elevation shadows, and the grid controls all need dark values too. The failure this prevents is quiet — a checkmark color left at #ffffff survives into dark mode and lands on a light-blue chip at 1.70:1, and nothing errors. It is also why pretable.css's dark block writes literals rather than aliasing one token to another: an alias makes it possible for an affordance to inherit a value that was only ever tuned for light mode. brand.css in the example above restates every one of them, including its own version of this exact checkbox pair.

Add color-scheme: dark to the block as well. It tells the browser to render form controls, scrollbars, and the default canvas in dark, which is a layer of the page your tokens do not reach.

Add density variants

If you want compact/standard/spacious tiers, add explicit blocks for the non-default tiers. Don't redefine the natural default — let :root handle that.

If your :root is the standard tier:

css
[data-density="compact"] {
  --pretable-row-height: 22px;
  --pretable-header-height: 26px;
  --pretable-group-panel-height: 28px;
  --pretable-cell-padding-x: 6px;
  --pretable-cell-padding-y: 2px;
  --pretable-group-indent: 12px;
  --pretable-font-size-cell: 13px;
  --pretable-font-size-header: 11px;
}
 
[data-density="spacious"] {
  --pretable-row-height: 40px;
  --pretable-header-height: 48px;
  --pretable-group-panel-height: 44px;
  --pretable-cell-padding-x: 16px;
  --pretable-cell-padding-y: 12px;
  --pretable-group-indent: 24px;
  --pretable-font-size-cell: 14px;
  --pretable-font-size-header: 13px;
}

Skip the [data-density="standard"] block since :root already provides those values. CSS handles the rest — when the user sets data-density="standard", no rule matches and the cascade falls back to :root.

Declare all three height tokens in every tier you write, and keep them strictly growing from compact to standard to spacious. Custom properties inherit, so a tier that simply forgets one silently resolves to the :root value and still looks like a valid <number>px to the engine — the omission is invisible until someone notices that the group panel did not change size.

Validate against the contract

Pretable doesn't enforce that custom themes define all 50 tokens — if you skip one, that token resolves to whatever else is in scope, or to the literal fallback grid.css supplies where one exists. @pretable/ui's own contract test holds all three shipped themes to the presence list, checks that every density tier resolves to <number>px, and checks that grid.css has no var(--pretable-*) reference a theme leaves unresolved. If you want the same assurance for your theme, that test is the pattern to copy.

The full token list is in Token reference.

When to ship a custom theme as a separate file vs. override at :root

ApproachWhen to use
Override individual tokens at :root1-5 tokens differ from a shipped theme. You want to stay close to that theme.
Author a custom theme file from scratchMost or all tokens differ. You want a dedicated brand theme.
BothCustom theme as the base, occasional overrides for dark-only tweaks.

For most apps, overriding individual tokens at :root is sufficient. Switch to a custom theme file when the override block grows past ~10 tokens or when you want to share your theme across multiple apps.

Where to go next