# Theming

> ngwr is themed entirely with CSS custom properties — no SCSS rebuild required for most changes. Wire up the providers, load the stylesheet, and you get light + dark out of the box with a palette you can rebrand at compile time or at runtime.

Source: https://ngwr.dev/guides/theming  
Kind: Core

## Wire it up

Two providers: theme (mode + persistence) and density (spacing scale).

```angular-ts
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.

```scss
// 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.

```angular-ts
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.

```scss
// 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.

```scss
// 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.

```scss
/* 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.

```scss
/* 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.

```scss
/* 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.

```scss
// 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.

```scss
// 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](https://ngwr.dev/guides/tokens/colors) — intents, the soft set, semantic neutrals and how dark mode flips. [Sizing](https://ngwr.dev/guides/tokens/sizing) — the control-sizing contract and the radius scale. [Typography](https://ngwr.dev/guides/tokens/typography) — the type scale, families and weights. [Density](https://ngwr.dev/guides/tokens/density) — the spacing scale, `provideWrDensity()` and the `[wrDensity]` subtree override. [Motion](https://ngwr.dev/guides/tokens/motion) — easings, durations and the overlay timing hook.

## See also

- [WrTheme](https://ngwr.dev/reference/services/theme) — The full API behind the toggle above — every input, method and signal it exposes.
- [WrDensity](https://ngwr.dev/reference/services/density) — The density counterpart — the other half of the appearance API.
- [Icons](https://ngwr.dev/icons) — Registering icon sets, the adapters, and the `ng g ngwr:icon-set` schematic.
