How it works
Density is four CSS custom properties — multipliers that default to 1. Setting a density writes [data-wr-density] (on <html> for the global scale, or on a subtree host for an override), and the stylesheet swaps the multipliers for that scope. Every control reads them through calc(), so changing density is a pure CSS-variable flip — no JS re-layout, no component churn. md resolves every multiplier to 1, so touching density is pure opt-in: controls without density-aware paddings just keep their static values.
| Name | Description | Type | Default |
|---|---|---|---|
--wr-density-y | Scales vertical padding (padding-y). The lever that changes control height. | number | 1 |
--wr-density-x | Scales horizontal padding (padding-x). | number | 1 |
--wr-density-text | Declared for consumers to read; no shipped component multiplies by it yet. | number | 1 |
--wr-density-gap | Declared for consumers to read; no shipped component multiplies by it yet. | number | 1 |
The scale
WrDensityValue is 'sm' | 'md' | 'lg' | 'touch', default md. Each value maps to a fixed set of multipliers, scoped by the [data-wr-density='…'] selectors in the density stylesheet.
| Name | Description | Type | Default |
|---|---|---|---|
sm | Compact — dense tables, toolbars, data-heavy screens. | y 0.55 · x 0.85 | text 0.95 · gap 0.8 |
md | Default. Every multiplier resolves to 1 — the baseline control sizing. | y 1 · x 1 | text 1 · gap 1 |
lg | Roomy — marketing pages, low-density forms. | y 1.35 · x 1.15 | text 1.05 · gap 1.15 |
touch | Finger-friendly (~44px targets). Leans on y + gap; text stays at reading size since touch is about hit area, not legibility. | y 1.7 · x 1.25 | text 1 · gap 1.5 |
How density meets control sizing
Controls never hardcode their height. Each declares its static padding-y / padding-x from the shared --wr-control-* contract (so a button, input and select line up pixel-for-pixel at every size), then multiplies that padding by the matching density scalar in calc(). Because height is line-height + 2×padding-y + 2×border, scaling padding-y is what actually changes the control's height — which is why touch leans almost entirely on --wr-density-y.
// How a control reads the multipliers. The control declares its static
// padding via the shared control-* contract, then multiplies by the
// density scalar in calc() — so it tracks density without re-rendering.
.wr-input {
padding: calc(var(--wr-input-padding-y) * var(--wr-density-y, 1))
calc(var(--wr-input-padding-x) * var(--wr-density-x, 1));
}Global density — provideWrDensity()
Configure the global scale at bootstrap. All fields are optional and merged with the defaults (defaultDensity: 'md', storageKey: 'wr-density', attribute: 'data-wr-density'). The chosen value is persisted via WrStorage under storageKey — pass storageKey: null to disable persistence. The provider has a stylesheet half, and it is not in the theme layer. The four multipliers are declared in ngwr/density and nowhere else, so @use 'ngwr' brings them and a per-component setup does not: without @use 'ngwr/density' every reader falls through to its var(--wr-density-y, 1) fallback, which means the controls look right at md and the provider, the service and the directive all appear to do nothing at all. Nothing warns — this is the failure to check first if a density switch has no visible effect.
import { bootstrapApplication } from '@angular/platform-browser';
import { provideWrDensity } from 'ngwr/density';
bootstrapApplication(AppComponent, {
providers: [
// All fields optional — merged with defaults.
// defaultDensity: 'md', storageKey: 'wr-density', attribute: 'data-wr-density'
provideWrDensity({ defaultDensity: 'sm' }),
],
});Changing it at runtime — WrDensity
Inject the WrDensity service to switch density live. set() writes [data-wr-density] on <html> and persists the value, cycle() steps through sm → md → lg → touch, and current() is a Signal<WrDensityValue> you can bind to. Unknown values passed to set() are ignored.
import { Component, inject } from '@angular/core';
import { WrDensity } from 'ngwr/density';
@Component({ /* … */ })
export class DensityToggle {
private readonly density = inject(WrDensity);
set() {
this.density.set('lg'); // switch globally — writes [data-wr-density] on <html>
this.density.cycle(); // sm → md → lg → touch → sm …
this.density.current(); // Signal<WrDensityValue> — read the active value
}
}Per-subtree override — [wrDensity]
Drop the wrDensity directive on any element to scope a density to that subtree only — it writes the same data-wr-density attribute on its host instead of <html>, so the multiplier rules cascade to descendants while the rest of the app keeps its global value.
<!-- The whole sidebar runs at sm; the rest of the app keeps its global density. -->
<aside wrDensity="sm">
<wr-list ...></wr-list>
</aside>