Theming Density switching
Density switching
Three density tiers — compact, standard, spacious — selected by data-density on <html> or on any wrapper element.
Pretable supports three density tiers — compact, standard, spacious — selected by the data-density attribute, normally on <html>. All three shipped themes define their own values per tier; switching is a single attribute toggle. The tier blocks are written as bare [data-density="…"] selectors and the tokens they set are inherited custom properties, so the attribute also works on a wrapper element when you want one region of the page at its own density.
Three buttons set data-density on a wrapper div; the caption below shows cell padding and the rendered row height moving together, because the engine resolves its JS-read density tokens against the grid's own element rather than document.documentElement.
Click through the three tiers above and watch the two numbers in the caption. Both move. The attribute is on a wrapper div rather than <html> so switching tiers doesn't resize every other grid on this docs page — padding follows it by plain CSS inheritance, and so does the rendered row height, which the engine reads in JavaScript against the grid's own element rather than the root. See The engine bridge for how that works and for the one place it stops short.
How it composes
Each theme's natural default lives at :root:
pretabledefaults to standard (48px rows).[data-density="compact"]and[data-density="spacious"]blocks override.- Excel defaults to compact (20px rows).
[data-density="standard"]and[data-density="spacious"]blocks override for roomier modes. - Material defaults to standard (48px rows).
[data-density="compact"]and[data-density="spacious"]blocks override.
When the consumer sets data-density="standard" on <html> while Excel is loaded, the standard block wins — but not on specificity. :root is a pseudo-class and [data-density="standard"] an attribute selector, and both weigh (0,1,0), so what hands it the win is source order: the tier blocks are written after :root in the same file. When the consumer removes the attribute, the standard block stops matching and the :root (compact) values reassert.
Density values are theme-coupled by design. Excel's "compact" (20px row) is tighter than the other two themes' "compact" (40px row) because each theme's identity includes its own density character. Picking compact in Excel and compact in
pretablegives you different absolute heights — the relationship is what's preserved across themes.
React state-driven
Same pattern as light/dark — hold density in state, sync to the DOM. <html> is the usual target, and the one the rest of this page assumes; writing the attribute onto a wrapper element instead works the same way, for grids inside that wrapper. See The engine bridge:
import { useEffect, useState } from "react";
type Density = "compact" | "standard" | "spacious";
export function DensityPicker({
density,
onChange,
}: {
density: Density;
onChange: (density: Density) => void;
}) {
useEffect(() => {
document.documentElement.dataset.density = density;
}, [density]);
return (
<div role="radiogroup" aria-label="Row density">
<button onClick={() => onChange("compact")}>Compact</button>
<button onClick={() => onChange("standard")}>Standard</button>
<button onClick={() => onChange("spacious")}>Spacious</button>
</div>
);
}Wrap your app with a <DensityPicker density={...} onChange={...} /> and hold the state in your app's root or persist it to localStorage.
The engine bridge
Three density tokens are read by the engine in JavaScript, not just by CSS:
--pretable-row-height— the height every row is drawn at, and the floor a measured row is clamped to.--pretable-header-height— used to position the sticky header and compute body viewport height.--pretable-group-panel-height— used to lay out the drag-to-group strip above the header.
The engine reads all three from the grid's own computed style, and subscribes with a MutationObserver to attribute changes on the grid element and on every ancestor up to and including <html>. When you flip data-density anywhere on that chain, the grid re-renders with new heights automatically — this is internal to <Pretable> and <PretableSurface>, and needs no wiring from you.
Resolving against the grid rather than the root is what lets these three tokens follow the same wrapper-scoping the rest of this theming section leans on. They are CSS custom properties, so they inherit; asking the grid element what --pretable-row-height resolves to gets the value the grid actually paints under, which is the wrapper's when a wrapper carries the attribute and the root's otherwise. That is the same question the browser answers for cell padding, asked in JavaScript. The demo at the top of this page is scoped to a div for the docs site's sake, and both of its numbers move together. Nothing about the root-level path changed: data-density on <html>, or a raw token override written at :root, behaves exactly as before.
One bounded gap remains, and it is worth knowing before you scope a very long grid. Rows the grid has actually rendered take the scoped height. The estimate for rows below the viewport that have never been rendered is a separate number — the row-layout controller's defaultRowHeight — and it is seeded once from <html> when the row model is first constructed, before the grid element exists for a ref to point at. So for a wrapper-scoped grid, the scroll extent contributed by never-yet-rendered rows is still charged at the root's row height, and the scrollbar is proportionally off until those rows scroll into view and get measured. The same staleness already applied to flipping data-density on <html> at runtime — the seed is read once either way — so scoping does not make it worse; it just does not fix it.
--pretable-row-heightis a floor, not a cap. A row whose content is taller — a wrapped text column, a two-line cell presentation — is measured and drawn at its content height, and the token decides only what a row that fits gets. That is what makes a mixed grid work: uniform rows at the density you asked for, taller ones exactly where the content needs them.With no theme imported at all, rows fall back to 44px. That is not a fourth density tier, it is the historical default for an unthemed grid, and it differs from
getDensityHeights's documented 32 because the two answer different questions — one is what to draw, the other what a caller reading density into their own layout should assume. Import a theme and neither number is reachable.
To read the row and header heights in your own code, @pretable/ui exports getDensityHeights. See Density helpers.
Composition with light/dark
Density and theme variants are independent. <html data-theme="dark" data-density="compact"> gives you the active theme's dark colors at its compact dimensions. The cascade resolves cleanly because [data-theme="dark"] overrides only colors and [data-density="compact"] overrides only density tokens.
Persisting density across reloads
Most apps persist density to localStorage:
import { useEffect, useState } from "react";
type Density = "compact" | "standard" | "spacious";
function readDensity(): Density {
if (typeof window === "undefined") return "standard";
const stored = window.localStorage.getItem("pretable-density");
return stored === "compact" || stored === "spacious" ? stored : "standard";
}
export function useDensity() {
const [density, setDensity] = useState<Density>(readDensity);
useEffect(() => {
document.documentElement.dataset.density = density;
window.localStorage.setItem("pretable-density", density);
}, [density]);
return [density, setDensity] as const;
}Use the hook in your density picker:
const [density, setDensity] = useDensity();
return <DensityPicker density={density} onChange={setDensity} />;Where to go next
- Override tokens — change density values themselves, not just which tier is active.
- Light / dark switching — composes with density.
- Density helpers — the
getDensityHeightsAPI.