Tokens

Density

One global knob that scales how tightly every control packs — sm, md (default), lg, touch. Controls multiply their paddings by a small set of density scalars, so the whole UI tightens or relaxes together without re-rendering.

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.

NameDescriptionTypeDefault
--wr-density-yScales vertical padding (padding-y). The lever that changes control height.number1
--wr-density-xScales horizontal padding (padding-x).number1
--wr-density-textDeclared for consumers to read; no shipped component multiplies by it yet.number1
--wr-density-gapDeclared for consumers to read; no shipped component multiplies by it yet.number1

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.

NameDescriptionTypeDefault
smCompact — dense tables, toolbars, data-heavy screens.y 0.55 · x 0.85text 0.95 · gap 0.8
mdDefault. Every multiplier resolves to 1 — the baseline control sizing.y 1 · x 1text 1 · gap 1
lgRoomy — marketing pages, low-density forms.y 1.35 · x 1.15text 1.05 · gap 1.15
touchFinger-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.25text 1 · gap 1.5
smButton
mdButton
lgButton
touchButton

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.

current(): md

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>

See also