Density Toggle
A compact / comfortable / spacious switch that publishes seven --density-* CSS variables to a whole subtree, with a 24px target floor the compact step cannot cross.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/density-toggle.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "Density Toggle" — a three-step display-density
switch whose entire output is a set of CSS custom properties, plus the control that
picks the step. Two exported components, one context hook.
Contract
- type Density = "compact" | "comfortable" | "spacious"; DENSITIES is the ordered tuple.
- DensityStep = { rowMinHeight, paddingX, paddingY, gap, controlMinHeight, fontSize,
lineHeight } — authored as plain numbers in CSS px (lineHeight unitless) at a 16px
root. defaultDensityScale ships all three steps:
compact 32 / 8 / 4 / 4 / 24 / 13 / 1.35
comfortable 40 / 12 / 8 / 8 / 32 / 14 / 1.5
spacious 52 / 16 / 12 / 12 / 40 / 15 / 1.7
- <DensityScope> — forwardRef, renders `as` (default "div"), spreads native props.
Props: value?, defaultValue = "comfortable", onValueChange?, storageKey?,
scale? (Partial<Record<Density, Partial<DensityStep>>>), as?.
It owns the value, publishes the variables on its own element, and provides
{ density, setDensity, step } through context.
- <DensityToggle> — forwardRef, the control. Props: value?, defaultValue?,
onValueChange?, label = "Density", options? (relabel/reorder/re-icon for i18n),
iconOnly?.
- useDensity() reads { density, setDensity, step } from the nearest scope and throws
outside one. `step` is the resolved numbers, for consumers that need a row height in
JS (a virtualizer, a canvas) rather than in CSS.
Behavior
- VARIABLES, NOT PROPS. This is the whole reason the component exists: the scope emits
--density-row-h, --density-pad-x, --density-pad-y, --density-gap,
--density-control-h, --density-font-size, --density-line-height, and descendants
follow by naming them. Never thread a `density` prop down a tree. font-size and
line-height are ALSO set on the scope element, since they inherit for free.
- THE TARGET FLOOR IS A CONTRACT, NOT A DEFAULT. --density-control-h is emitted as
`max(<step>px, var(--density-touch-floor, 24px))`, and one hoisted stylesheet sets
`@media (pointer: coarse){[data-density-scope]{--density-touch-floor:44px}}`. So the
compact step is 24px on a mouse (WCAG 2.2 SC 2.5.8 AA) and 44px on touch, and the
clamp in resolveDensityStep independently refuses any `scale` value below 24. Do NOT
write `--density-control-h: max(var(--density-control-h), 44px)` — a custom property
referencing itself is a cyclic dependency and computes to unset, i.e. no floor at all.
- Every `scale` field is clamped: non-finite (NaN / Infinity) and negative values fall
back to the shipped number, lengths floor at 0, lineHeight at 1, controlMinHeight
at 24.
- Ownership is decided once and never mixed. DensityScope: `value` -> the parent owns
it; else a non-empty `storageKey` -> localStorage owns it; else internal state.
DensityToggle: `value` -> the parent; else an enclosing scope; else internal state.
onValueChange fires in every mode.
- PERSISTENCE MUST SURVIVE SSR. Back storageKey with a useSyncExternalStore-based
localStorage hook whose getServerSnapshot returns the default: the server and the
first client frame render the same markup, and the stored step is adopted after
hydration. Reading localStorage during render is the bug this avoids. A hand-edited
or stale stored value that is not one of the three steps normalizes to the default
instead of emitting junk CSS.
- ARIA: the three steps are mutually exclusive and exactly one is always active, so the
control is role="radiogroup" with role="radio" + aria-checked children — NOT an
aria-pressed toolbar, which would tell a screen reader that several could be on at
once. One tab stop for the group (tabIndex 0 on the checked radio, -1 on the rest);
arrow keys move AND select, because in a radiogroup focus follows selection:
Right/Down = next, Left/Up = previous, Home/End = ends, wrapping at both ends, and
modifier + arrow is left to the OS.
- The control's own size never follows the density it sets. A control that shrinks as
you compact it is a moving pointer target and would be the smallest thing on screen
at the compact step.
Rendering & styling
- Semantic tokens only: bg-muted/60, bg-background, text-foreground,
text-muted-foreground, border, ring, shadow-sm. No hex, no rgb(), no oklch().
The component deliberately sets no colour and no radius of its own — colour and
radius are a theming concern, not a density one.
- cn() merges the consumer className on both components; the scope spreads native props
and carries data-density="<step>" plus data-density-scope for styling hooks and tests.
- The only animation is the toggle's transition-colors, killed under
motion-reduce:transition-none. Changing density is instant on purpose — animating
every descendant's height would need the descendants' cooperation and would jank a
long table; a consumer who wants it adds a transition on their own rows.
- focus-visible:ring-2 ring-ring ring-offset-1 on each radio.
Customization levers
- The scale: pass `scale={{ compact: { rowMinHeight: 28, paddingY: 2 } }}` to retune one
step, or all three, to your own spacing system. Only the fields you name change; the
24px control floor still applies.
- More or fewer steps: the API is a value union — a two-step "compact / default" or a
four-step scale means extending Density, defaultDensityScale and `options` together.
- More variables: add to DensityStep + densityStepToVars if your surface needs, say,
--density-icon-size or --density-avatar-size. Keep them layout-only; colour and radius
belong to the theme, not the density.
- Control shape: `iconOnly` for a tight toolbar, `options` to relabel for i18n or to
swap the Rows4 / Rows3 / Rows2 icons; the group is an inline-flex pill, so it drops
into a toolbar unchanged.
- Placement: put the scope around the whole page for a global preference, or around one
panel for a local one — nested scopes each win inside their own subtree.
- Persistence: storageKey for a per-browser preference; drop it and lift the value with
value/onValueChange when the density belongs to a saved view or a server-side profile.Concepts
The seven properties DensityScope publishes, and what each step sets them to. A descendant opts
in by naming a variable — never by receiving a prop:
| Variable | Meaning | compact · comfortable · spacious |
|---|---|---|
--density-row-h | minimum height of one row / list item | 32px · 40px · 52px |
--density-pad-x | horizontal padding inside a cell or row | 8px · 12px · 16px |
--density-pad-y | vertical padding inside a cell or row | 4px · 8px · 12px |
--density-gap | gap between stacked items | 4px · 8px · 12px |
--density-control-h | minimum size of an interactive target | 24px · 32px · 40px (never below 24px; 44px on coarse pointers) |
--density-font-size | body font size, emitted in rem | 0.8125rem · 0.875rem · 0.9375rem |
--density-line-height | unitless line height | 1.35 · 1.5 · 1.7 |
--density-font-size and --density-line-height are also applied to the scope element itself,
because those two inherit — text follows with no opt-in at all. The other five stay inert until a
descendant reads them:
<DensityScope storageKey="inbox-density">
<DensityToggle />
<table>
<tr className="h-[var(--density-row-h)]">
<td className="px-[var(--density-pad-x)] py-[var(--density-pad-y)]">…</td>
</tr>
</table>
<button className="min-h-[var(--density-control-h)] min-w-[var(--density-control-h)]">…</button>
</DensityScope>Use h- on a <tr> (a table row treats height as a minimum) and min-h- everywhere else, so a
row that needs two lines can still grow. Scopes nest: an inner DensityScope wins inside its own
subtree.
- Density as a variable scope — the component's whole output is seven inherited custom properties on one element. Descendants opt in by naming a variable, so a table nested ten levels deep follows along without a single prop being threaded through it, and two scopes on one page can hold different densities at once.
- A floor that is a contract, not a default —
--density-control-hresolves asmax(<step>px, var(--density-touch-floor, 24px)), and a hoisted@media (pointer: coarse)rule raises that floor to 44px. The compact step therefore cannot produce a target below the WCAG 2.2 minimum, even when a consumer's ownscaleasks for 8px; a second, independent clamp inresolveDensitySteprefuses the same value in JS before it ever reaches CSS. - Cyclic custom properties compute to nothing — the tempting
--density-control-h: max(var(--density-control-h), 44px)is a self-reference, which CSS treats as invalid at computed-value time; the floor has to arrive through a separate variable, which is why--density-touch-floorexists. - Radiogroup, not a pressed toolbar — three mutually exclusive steps with exactly one always active is
aria-checkedsemantics;aria-pressedwould announce three independent toggles. That difference also decides the keyboard model: focus follows selection, so an arrow key both moves and picks. - Storage that survives the server — the persisted value is read through
useSyncExternalStorewith a server snapshot equal to the default, so the server render and the first client frame agree and the stored step is adopted only after hydration. A stale or hand-edited value that is not one of the three steps normalizes back to the default rather than emitting junk CSS. - Decided ownership, never mixed — controlled (
value), persisted (storageKey) and uncontrolled are three modes chosen once at mount; whichever holds the value,onValueChangestill reports every change, so a parent can observe without taking over.
Client Only
A wrapper that renders its children only after mount — the server and the first hydration render both emit the fallback, so browser-only values can never produce a hydration mismatch.
Custom Cursor
A pointer replacement scoped to one container — a spring-driven dot that opens into a difference-blended disc over anything interactive, hides the native arrow only while you are inside the box, and never draws on a coarse pointer or under reduced motion.