Grid Density helpers
Density helpers
Read density tokens (compact, standard, spacious) into JavaScript at runtime with getDensityHeights.
Density is defined in CSS — --pretable-row-height and --pretable-header-height, switched by data-density on <html>, or on any wrapper element when one region of the page runs at its own density. See Theming > Density for the tokens themselves.
Sometimes you need those two values as numbers in JavaScript: to size a custom virtualizer, to position an overlay over a row, to assert a height in a test. getDensityHeights from @pretable/ui is the public way to read them.
The useDensityHeights recipe from this page run twice — once given a boxed wrapper whose data-density the buttons flip, once given null so it reads the document root — with a readout showing only the scoped one moving.
The demo above runs the useDensityHeights recipe from this page twice, over
a boxed wrapper whose own data-density the buttons flip. The two readings
differ only in the element handed to the hook: the "Boxed wrapper" row passes
the wrapper and changes with every click, while the "Page root" row passes
null, which resolves document.documentElement and never moves. Which
element you pass is the whole of the mechanism.
getDensityHeights
import { getDensityHeights, type DensityHeights } from "@pretable/ui";
// Reads the document root.
const { rowHeight, headerHeight } = getDensityHeights();
// Reads whatever `element` paints under — a wrapper's `data-density` included.
const scoped = getDensityHeights(element);A synchronous snapshot with the signature getDensityHeights(element?: Element | null): DensityHeights. It reads the two variables off the given element's computed style — or off document.documentElement's when you pass nothing — and parses them into numbers:
interface DensityHeights {
rowHeight: number;
headerHeight: number;
}Nothing else is in the package — getDensityHeights and DensityHeights are the whole of @pretable/ui's JavaScript surface. The rest of it is CSS.
Which element to pass
These are CSS custom properties, so they inherit. Passing an element resolves the value that element actually paints under, which is what a data-density scoped to a wrapper (<div data-density="compact">…) sets — the root's own computed style never sees it. Passing nothing reads the root, which is right only when the attribute lives on <html>.
Pass the element whose geometry you are computing: your own grid chrome's DOM node, or any descendant of the scoping wrapper. @pretable/react passes the grid's scroll viewport internally, which is why a wrapper-scoped <Pretable> measures at the density it paints at.
A detached element resolves nothing in most browsers, so read it after mount, never during the render that creates it. The first render has no element to hand over, and null is the honest answer there — it falls back to the root, which is what the grid draws with until the real element exists.
What it parses, and what it falls back to
Only a <number>px value parses. A variable that is unset, empty, or written in any other unit (2rem, calc(…), auto) falls back:
| Variable | Fallback |
|---|---|
--pretable-row-height | 32 |
--pretable-header-height | 36 |
These are the same fallbacks the grid itself uses, so a value it cannot parse produces the grid's own default geometry rather than a broken one. If you set density tokens in a unit other than px, this function will not see them.
SSR
Safe to call on the server: with no document, it returns the fallbacks. That is a real answer rather than a guess, because the fallbacks are what the grid renders with before CSS is resolved — server output and the first client paint agree.
It is a snapshot, not a subscription
getDensityHeights reads once. It does not watch anything, so a value you captured at mount is stale the moment the consumer flips data-density or swaps themes. That is deliberate — most callers want one read at one moment, and a subscription they did not ask for costs a getComputedStyle on every render.
The grid does not need you to do anything about this: <Pretable> and <PretableSurface> track density changes internally and re-render themselves. You only need the recipe below if your own component's layout depends on the heights.
Recipe: making it reactive
If you are rendering your own grid chrome with usePretable and need heights that follow a density or theme swap, wrap the snapshot in a store subscription — this is the recipe the demo above runs, unmodified, for both of its readings. It takes the element to resolve against, so it picks up a wrapper-scoped data-density for the same reason getDensityHeights does.
import { useCallback, useRef, useSyncExternalStore } from "react";
import { getDensityHeights, type DensityHeights } from "@pretable/ui";
const SERVER_SNAPSHOT: DensityHeights = { rowHeight: 32, headerHeight: 36 };
// Every element whose attributes could change what `element` resolves to: the
// element itself, then each ancestor up to `<html>`. The density tokens are
// inherited custom properties, so a `data-density` anywhere on that chain is
// what the element paints under — watching the root alone would miss a swap on
// a wrapper, and watching the wrapper alone would miss one on the root.
function scopeChain(element: Element | null): Element[] {
const chain: Element[] = [];
for (
let current = element;
current !== null;
current = current.parentElement
) {
chain.push(current);
}
const root = document.documentElement;
if (!chain.includes(root)) chain.push(root);
return chain;
}
function subscribe(element: Element | null, onChange: () => void): () => void {
if (typeof document === "undefined") return () => {};
const observer = new MutationObserver(onChange);
for (const node of scopeChain(element)) {
observer.observe(node, {
attributes: true,
attributeFilter: ["data-density", "data-theme", "class", "style"],
});
}
return () => observer.disconnect();
}
export function useDensityHeights(element: Element | null): DensityHeights {
const cached = useRef<DensityHeights | null>(null);
const subscribeToScope = useCallback(
(onChange: () => void) => subscribe(element, onChange),
[element],
);
const getSnapshot = useCallback(() => {
const next = getDensityHeights(element);
const prev = cached.current;
// `useSyncExternalStore` calls this on every render and compares by
// reference. Returning a fresh object each time is an infinite render
// loop, so hand back the previous one when the numbers have not moved.
if (
prev !== null &&
prev.rowHeight === next.rowHeight &&
prev.headerHeight === next.headerHeight
) {
return prev;
}
cached.current = next;
return next;
}, [element]);
return useSyncExternalStore(
subscribeToScope,
getSnapshot,
() => SERVER_SNAPSHOT,
);
}Four things worth understanding before you rely on it:
- Hold the element in state, not a ref.
subscribeandgetSnapshothave to change identity when the element does, oruseSyncExternalStorekeeps the old subscription and the old reading. A ref's.currentmutating does not re-render, so the demo above usesref={setWrapper}— a callback ref intouseState— and passes that value. Until it lands, the element isnulland the hook reports the root, which is the right answer for a component that has not mounted its DOM yet. - The
MutationObserverfilter is a guess about how themes change. It fires ondata-density,data-theme,classandstylechanges anywhere on the scope chain. If your theme switcher works some other way — a stylesheet swap, a variable set on an element outside that chain — extend the filter or the observed set. - It cannot see a plain stylesheet change. Nothing observes CSS itself. The attribute change is the signal; if the value changes without one, nothing re-renders.
- Hydration reads the fallbacks first. React uses the server snapshot for the hydrating render and re-checks immediately after, so the first painted frame uses
32/36and the real values land on the next commit. That is what keeps server and client markup identical; it also means you should not assume the CSS values are available on the very first render.
Where to go next
- Theming > Density switching — the tokens, the tiers, and wiring
data-densityfrom React state. - Custom rendering —
usePretablewith your own header and rows. - Token reference — every
--pretable-*variable.