Wire it up
Two providers: theme (mode + persistence) and density (spacing scale).
import { bootstrapApplication } from '@angular/platform-browser';
import { provideWrTheme } from 'ngwr/theme';
import { provideWrDensity } from 'ngwr/density';
bootstrapApplication(AppComponent, {
providers: [
provideWrTheme({ defaultMode: 'auto' }), // 'light' | 'dark' | 'auto'
provideWrDensity({ defaultDensity: 'md' }), // 'sm' | 'md' | 'lg' | 'touch'
],
});Load the stylesheet
One umbrella for everything, or pick individual entry-points if you bundle by component.
// styles.scss — load the umbrella stylesheet.
// Pulls in every component's CSS, design tokens, dark mode, density vars.
@use 'ngwr' as *;
// Or per-entry-point if you bundle by component:
@use 'ngwr/button';
@use 'ngwr/input';
@use 'ngwr/theme';Light / dark / auto
mode is what the user picked. resolved() is what the DOM actually has — auto resolves through prefers-color-scheme. Writes data-theme on <html> and persists via WrStorage.
import { inject } from '@angular/core';
import { WrTheme } from 'ngwr/theme';
const theme = inject(WrTheme);
theme.set('dark'); // explicit
theme.set('auto'); // follow prefers-color-scheme
theme.toggle(); // flip light ↔ dark
theme.resolved(); // 'light' | 'dark' — what the DOM has
// Tune dark-mode tokens with the theme.dark mixin, which builds the selector
// from $theme-attribute — a hand-written [data-theme='dark'] stops matching the
// moment anyone renames the attribute, silently.
// There is no --wr-color-bg: in dark, --wr-color-white IS the canvas and
// --wr-color-dark IS the ink — the two neutrals swap jobs.
@include theme.dark {
--wr-color-white: #0c0d10;
--wr-color-dark: #f5f6f8;
}Renaming the theme attribute
provideWrTheme({ attribute }) moves the attribute WrTheme writes on <html> — useful when another design system on the page already owns data-theme. It has a second half: a CSS selector cannot read a provider value, so the stylesheet takes the same name as the Sass variable $theme-attribute, and the two must be set to the same string. Set only the provider and dark mode silently never applies — the service reports resolved() === 'dark', the attribute lands on <html>, and nothing matches it. In development WrTheme compares the two and warns when they disagree; the compiled stylesheet publishes what it keys on as --wr-theme-attribute so it can.
// The attribute is configurable, and it has TWO halves that must agree.
// A CSS selector cannot read a provider value, so the stylesheet takes the
// same name as a Sass variable. Set one without the other and dark mode
// silently never applies.
// 1. styles.scss — configure the stylesheet FIRST, before anything that
// pulls the theme in. Sass refuses to configure a module that is already
// loaded, so a component entry point above this line is a build error.
@use 'ngwr' with ($theme-attribute: 'data-color-mode');
// 2. app.config.ts — the same string.
provideWrTheme({ attribute: 'data-color-mode' })
// Only the configured attribute is emitted; 'data-theme' is NOT kept as a
// second selector. Renaming is how you decouple ngwr from another design
// system that already owns data-theme, and emitting both would hand that
// system control of your tokens again.
// Your own dark overrides follow the same name. Reach for the mixin instead
// of writing the literal, and they move with it:
@use 'ngwr/theme' as theme;
.my-hero {
background: #fff;
@include theme.dark {
background: #0b1120;
}
}Rebrand — at compile time
Pass your hex values to the SCSS palette module before @use 'ngwr'. NGWR re-derives all variants (dark, darker, light, lighter, contrast) — in the light theme, which is the whole of what $base-colors configures. Dark mode does not follow, and that is a boundary rather than an oversight: the dark intents are hand-tuned for the dark canvas, not lightened from the light ones. success and danger are moved 33% and 21% so their labels can go white on a dark ground, warning is deliberately left where it is because white cannot reach it at any tone worth shipping, and primary is deepened for the same flip — a direction no formula would take on its own. Deriving dark from your seed would swap a visibly wrong colour for an invisible failing contrast ratio, so the library refuses to guess and hands you the same arithmetic instead: theme.rebrand(), wrapped in theme.dark. Pick the dark seed FOR the dark canvas — usually lighter than the light one — and measure it.
// Rebrand at compile time — configure the palette on the theme entry point.
// NGWR re-derives -dark / -darker / -light / -lighter / -contrast variants.
//
// THIS IS THE LIGHT PALETTE. It does not reach dark mode: the dark intents are
// hand-tuned for the dark canvas, not lightened from these, so seeding indigo
// here gives indigo in light and the shipped blue in dark. Carry it across
// yourself with `rebrand()` — the second block below — and pick the dark seed
// for a dark ground rather than reusing the light one.
//
// The palette is ONE Sass map, and configuring a map REPLACES it — it is not
// merged with the defaults. List every intent you want to exist: an omitted
// key leaves `--wr-color-<intent>` undefined in light mode, which also breaks
// everything derived from it (-soft, -contrast, -rgb).
@use 'ngwr/theme' with (
$base-colors: (
primary: #6366f1, // indigo-500
secondary: #14b8a6, // teal-500
success: #22c55e,
warning: #f59e0b,
danger: #f43f5e,
info: #3472d9,
light: #cbd5e1,
medium: #6a7683,
dark: #0f172a,
)
);
@use 'ngwr' as *;
// Carry the rebrand into dark mode. `rebrand()` is the same arithmetic the
// light palette runs, on whatever element you include it on — and `theme.dark`
// builds the selector from `$theme-attribute`, so it survives a renamed
// attribute where a hand-written [data-theme='dark'] would not.
@use 'ngwr/theme' as theme;
@include theme.dark {
// Seeded FOR the dark canvas, and measured: `-contrast` PICKS black or
// white, so the fill decides its own label. #5b5bd6 takes white at 5.37:1
// (the shipped dark intents all do); indigo-400 #818cf8 would take black,
// which is a different design, not a lighter one.
@include theme.rebrand((primary: #5b5bd6));
}Rebrand — one subtree
Recolouring a section is theme.rebrand() on that selector, and hand-writing the three obvious properties is not enough — it leaves every derived token on the page's own hue, in two different ways. The four shades (-dark, -darker, -light, -lighter) are Sass arithmetic resolved at compile time, so no runtime value reaches them and a pink button turns blue on hover, which paints -dark. The tint and ink family (-soft, -soft-border, -soft-contrast, -active, -ink) IS written in terms of var() — but a custom property's references are substituted on the element that declares it, and those are declared on :root, so what inherits into your section is the substituted literal. An outlined button inside a pink .marketing drew a pink border around blue text. The mixin does both halves: it recomputes the shades and re-includes the composed tokens on your element, where they resolve against your base.
/* Recolour a SUBTREE — use `rebrand()`, not a hand-written triple.
It emits the whole family for each intent you name: the base, `-rgb` and
`-contrast`, the four shades `-dark` / `-darker` / `-light` / `-lighter`,
and a re-resolved `-soft` / `-soft-border` / `-soft-contrast` / `-active` /
`-ink`. */
@use 'ngwr/theme' as theme;
.marketing {
@include theme.rebrand((primary: #be123c));
}
/* Why not three properties by hand. Setting only the base, `-rgb` and
`-contrast` leaves the rest of the family on the page's own hue, and it fails
in two different ways at once:
- `-dark` / `-darker` / `-light` / `-lighter` are Sass arithmetic, resolved
when the stylesheet is COMPILED. No runtime value feeds them, so a pink
button turned blue on :hover, which paints `-dark`.
- `-soft`, `-soft-border`, `-soft-contrast`, `-active` and `-ink` are written
in terms of var(), which is what lets them re-derive — but a custom
property's references are substituted on the element that DECLARES it, and
these are declared on :root. What inherits into the subtree is the
substituted literal, so an outlined button inside `.marketing` drew a pink
border around blue text.
Both are why the mixin exists. It is compile-time, so the seed has to be known
when your stylesheet is built; for a colour chosen at runtime reach for
`wrThemeTokens()` from 'ngwr/theme' instead. */Rebrand — at runtime
Override the CSS variables on :root. Set the base color, the -rgb channel — components use it for rgba() rings and tints — and the -contrast, which is the one the compile-time path derives for you and this one does not: it was baked from the old fill, so a runtime base change leaves the label behind. Re-pick it the way the theme does, by taking whichever of black or white scores higher against the new fill. On :root the tint and ink family re-resolves by itself, because it is declared on the same element you are overriding; the four shades still cannot, so set those too — or let wrThemeTokens() compute all seven from one hex, which is what it is for.
/* Rebrand at runtime — override on `:root`.
Set the base color, the rgb channel (`-rgb` powers rgba() rings) AND the
contrast: `-contrast` was picked at SCSS compile time from the OLD fill, so
it does not follow a value you set here. #4f46e5 takes white at 6.3:1. */
:root {
--wr-color-primary: #4f46e5;
--wr-color-primary-rgb: 79, 70, 229;
--wr-color-primary-contrast: #ffffff;
}
/* Two families do NOT follow, and this is the ceiling of the runtime path.
`-soft` / `-soft-border` / `-soft-contrast` / `-active` / `-ink` re-resolve
on their own, because they are declared on :root in terms of var() and this
override lands on the same element. The four SHADES are Sass arithmetic and
cannot: set them yourself, or use `wrThemeTokens()`, which computes all seven
from one hex. */
:root {
--wr-color-primary-dark: #4338ca;
--wr-color-primary-darker: #3730a3;
--wr-color-primary-light: #6366f1;
--wr-color-primary-lighter: #818cf8;
}Per-component tokens
Every component also exposes its own scoped CSS variables (--wr-btn-radius, --wr-tag-bg, …). Override at the element level when you only need to nudge one widget.
/* Components also expose per-instance vars — override on the element.
No need to ship a full theme just to nudge one widget. */
.wr-btn {
--wr-btn-radius: 999px;
--wr-btn-padding-x: 1.5rem;
}
/* Or inline on the host: */
<wr-tag style="--wr-tag-bg: #fef3c7; --wr-tag-color: #92400e">soon</wr-tag>Reduced motion
The theme layer ships one @media (prefers-reduced-motion: reduce) block covering the always-on chrome — the enter animations on dialog, drawer, dropdown, popconfirm, toast, the lightbox viewer and the responsive bottom sheet, plus the skeleton and lightbox shimmers. You get it with the stylesheet; there is nothing to opt into. What it does is decided per animation rather than globally: an enter animation is removed (the panel still arrives, it just does not travel), a decorative shimmer is removed, a spinner is slowed rather than stopped — a frozen spinner reads as hung, not as calm — and the toast's progress bar keeps running, because it is a countdown and a bar frozen full would state something untrue. Override any of it on the same classes; the block is !important only because the theme layer loads before every component stylesheet.
Every !important in the library
Thirty-five declarations, and knowing where they are is the difference between an override that works and an afternoon. Eighteen are the reduced-motion block above and its per-component counterparts — marquee, star-border, glitch-text, shiny-text, gradient-text, window, and the ngwr/animations utility classes — where !important is structural: the theme layer loads before every component stylesheet, so an ordinary rule there loses to the animation it is trying to remove. The other seventeen sit on six selectors, and fifteen of them are fighting an inline style rather than your CSS — CDK's, written onto the pane after attach and re-written on every reposition, which no ordinary rule at any specificity can reach. The remaining two are .wr-window--no-anim, the window's own opt-out modifier, doing to its subtree what you would do by hand.
// Every !important outside the reduced-motion blocks — 17 declarations on
// six selectors, and CDK's inline styles are what fifteen of them fight.
.wr-overlay-sheet // 8 — the mobile bottom sheet
.wr-overlay-sheet > *:not(.wr-dialog__close) // 4 — its direct children
.wr-context-menu-overlay { position: fixed } // 1 — undoes inline position: static
.wr-dialog-panel { position: relative }// 1 — same, so the × can be parked
.wr-drawer__panel { position: relative }// 1 — same, service-opened drawers
.wr-window--no-anim, .wr-window--no-anim * // 2 — the component's own opt-out
// And inside @media (prefers-reduced-motion: reduce), 18 more: the theme
// layer's shared block plus marquee, star-border, glitch-text, shiny-text,
// gradient-text, window and the ngwr/animations utilities.Overriding the bottom sheet takes an !important of your own, in a stylesheet that loads after ngwr's, at equal or higher specificity — that is the whole trick, and a rule on the BEM class without it simply does nothing. Twelve of the seventeen are on .wr-overlay-sheet, the presentation dialog, select, dropdown and popover share on small viewports: eight on the sheet itself (width, min / max width, the two max-height declarations, the top-only radius, the keyboard lift and the slide-up animation) and four stretching its direct children and squaring their bottom corners. Reach for the sheet's own tokens first — --wr-overlay-duration and --wr-overlay-ease are read from var(), so a custom property beats the declaration without any specificity fight at all.
// styles.scss — after @use 'ngwr', so source order is already on your side.
@use 'ngwr';
// A plain rule loses. This is not a specificity problem; it is the !important.
.wr-overlay-sheet {
max-height: 70dvh; // ✗ never applies
}
// Match it. Equal specificity + !important + later in the cascade wins.
.wr-overlay-sheet {
max-height: 70dvh !important; // ✓
border-radius: 1.5rem 1.5rem 0 0 !important;
}
// Better where a token exists: var() resolves before the cascade cares.
.wr-overlay-sheet {
--wr-overlay-duration: 0.32s;
--wr-overlay-ease: cubic-bezier(0.32, 0.72, 0, 1);
}One of the twelve cannot be beaten by a plain rule at any specificity.margin-bottom on the sheet is fighting an inline style: all four sheet users pin the pane with a CDK GlobalPositionStrategy, whose apply() writes margin-bottom: 0px onto that very element on every reposition. The same is true of the three position declarations — .wr-context-menu-overlay, .wr-dialog-panel and .wr-drawer__panel each undo an inline position: static that CDK re-applies after attach. If you need different geometry there, change what the strategy is given rather than what the rule says: the panel width and the radius are ordinary declarations you can win.
What is stable, and what is not
Three layers, and they carry different promises. The global tokens (--wr-color-*, --wr-border-radius-*, --wr-text-*) and the per-component tokens (--wr-btn-radius, --wr-tag-bg) are public API: they are named deliberately and a rename is a breaking change with a migration note. The BEM class names (.wr-checkbox__box, .wr-collapse__body) are public too — components render with ViewEncapsulation.None precisely so you can reach them, and they are treated as API when the library changes: renaming or restructuring one is breaking, and the changelog says so. What is not stable is everything a class name does not cover — the element nesting inside a component, which element carries which class, and the presence of a wrapper. So a class NAME is safe to key on across a minor; a descendant selector that assumes a shape (.wr-select__trigger > span > svg) is not. A stable name is not the same as a winning rule, and the difference bites 27 components — card, list, breadcrumbs, cascader, lightbox, loading-bar, virtual-scroll, drag-drop and the nineteen animation and canvas ones each ship their own stylesheet, which Angular emits as a <style> block AFTER your linked styles.css. At equal specificity theirs wins, so .wr-card { background: … } in your global sheet does nothing while .wr-btn { … } works. Override those through their own token (--wr-card-bg), which is what the token catalogue on each component page is for, or raise specificity (wr-card.wr-card). Everything else in the catalog has no component stylesheet and takes a plain single-class rule. Anything you style should also be something you can see break: pin exact versions and keep a visual regression test, because a token change repaints without failing a build.
Where to go next
Theming wires the system up; the tokens are catalogued one family per page. Colors — intents, the soft set, semantic neutrals and how dark mode flips. Sizing — the control-sizing contract and the radius scale. Typography — the type scale, families and weights. Density — the spacing scale, provideWrDensity() and the [wrDensity] subtree override. Motion — easings, durations and the overlay timing hook.