Install
import { provideWrTheme, WrTheme } from 'ngwr/theme';
bootstrapApplication(AppComponent, {
providers: [provideWrTheme({ defaultMode: 'auto' })],
});Usage
private readonly theme = inject(WrTheme);
this.theme.set('dark');
this.theme.toggle();
protected readonly resolved = this.theme.resolved; // Signal<'light' | 'dark'>
protected readonly mode = this.theme.mode; // Signal<'light' | 'dark' | 'auto'>SSR: no flash of the wrong theme
A server has no localStorage and no prefers-color-scheme, so every server-rendered or prerendered page ships the same attribute — light under the default config. A visitor whose OS is dark, or who chose dark here, sees a white page until the client bundle boots and WrTheme corrects it: milliseconds on localhost, seconds on a slow connection. No provider can close that window, because by the time Angular runs the browser has already painted.
The fix is a blocking script in <head>, above every stylesheet, that resolves the theme before first paint. wrThemePrePaintScript() emits one — reading the persisted mode exactly the way WrTheme and WrStorage agree to write it — the JSON envelope, the key prefix and the TTL — so nothing here has to be re-derived from the bundle or kept in step by hand. This page's own output, ready to paste:
<script>(function(){try{var m=null,r=null;try{r=window.localStorage.getItem("wr-theme");}catch(e){}if(r!==null){try{var p=JSON.parse(r);if(p!==null&&typeof p==="object"&&"v" in p){if(p.e===undefined||p.e>=Date.now())m=p.v;}else{m=p;}}catch(e){m=r;}}if(m!=="light"&&m!=="dark"&&m!=="auto")m="auto";var dark=m==="dark"||(m==="auto"&&typeof window.matchMedia==="function"&&window.matchMedia("(prefers-color-scheme: dark)").matches);document.documentElement.setAttribute("data-theme",dark?"dark":"light");}catch(e){}})();</script> It is plain ES5 with no dependency on the app bundle, and every step is wrapped: a blocked localStorage (private mode, a sandboxed iframe) leaves the rendered default in place rather than throwing before anything has rendered. The docs site you are reading runs this same script, and a spec in the library pins the two to the same answers.
import { wrThemePrePaintScript } from 'ngwr/theme';
// Same script, for an app that renamed the attribute, the key or the prefix.
// Pass the same values you gave provideWrTheme() / provideWrStorage():
wrThemePrePaintScript({
attribute: 'app-theme', // provideWrTheme({ attribute })
storageKey: 'theme-mode', // provideWrTheme({ storageKey })
defaultMode: 'dark', // provideWrTheme({ defaultMode })
storagePrefix: 'myapp:', // provideWrStorage({ prefix })
});
// It returns the JS source with NO <script> wrapper, so an SSR template can
// inline it — and a CSP that forbids 'unsafe-inline' can hash it instead of
// reaching for a nonce:
const source = wrThemePrePaintScript();
const hash = createHash('sha256').update(source).digest('base64');
// Content-Security-Policy: script-src 'sha256-${hash}'One service per application, one attribute per document
WrTheme is root-provided: one instance for the whole application, created by provideWrTheme()'s initializer at bootstrap. It reads WR_THEME_CONFIG once, from the root injector — so a provideWrTheme() in a lazy route's providers sets a token the service will never look at, and the route's defaultMode or storageKey is silently the bootstrap one. Configure it once, at bootstrap. WrDensity, WrI18n, WrStorage and WrToast follow the same rule for the same reason; provideWrConfig() and provideWrIcons() deliberately do not, because the things that read those resolve them from their own injector.
The document, though, has exactly one <html> — and that is what matters if you run two Angular applications on one page (a micro-frontend shell plus a widget, or two mounted bundles). Each gets its own WrTheme; both write the same attribute and both persist to the same localStorage key. The attribute is last-writer-wins, and each service's resolved() keeps reporting its own answer — so one of them can confidently report light while the page is painted dark, and after a reload the theme is decided by whichever application initialised last. Overlay containers do not collide this way; the theme does, because the thing being shared is the document.
// Shell — the one application that owns <html data-theme> and the storage key.
bootstrapApplication(ShellComponent, {
providers: [provideWrTheme({ defaultMode: 'auto' })],
});
// Widget mounted on the same page — no provideWrTheme(), no inject(WrTheme).
// Its ngwr components are themed already: the attribute and the tokens are on
// the document, not on the injector.
bootstrapApplication(WidgetComponent);
// If the widget needs the value in TypeScript, observe the document instead of
// constructing a second owner of it.
const html = document.documentElement;
const theme = signal(html.getAttribute('data-theme') ?? 'light');
new MutationObserver(() => theme.set(html.getAttribute('data-theme') ?? 'light'))
.observe(html, { attributeFilter: ['data-theme'] });
// Unavoidable second instance? At least stop the persisted choices colliding.
bootstrapApplication(WidgetComponent, {
providers: [provideWrTheme({ storageKey: null })],
}); Note that writing is not limited to set(): the service mirrors its resolved theme onto <html> from an effect the moment it is constructed, so a second application becomes a writer simply by injecting WrTheme to read the current value. The rule that follows is a short one — exactly one application on the page calls provideWrTheme() or injects the service. The others need nothing: the attribute and the whole --wr-* layer live on the document, so their components are already themed, and a widget that must react in TypeScript can watch the attribute rather than own it. Where a second instance is unavoidable, give it a distinct storageKey — or null, which turns persistence off — so at least the two stop overwriting each other's choice.
Why ngwr provides this
Theme switching sounds like one line of CSS until you handle persistence, the prefers-color-scheme fallback, SSR (no localStorage), and flashing the wrong theme on first paint. WrTheme owns that lifecycle and exposes the result as signals.
API
| Name | Description | Type | Default |
|---|---|---|---|
mode | User-selected mode — 'light' | 'dark' | 'auto'. | Signal<WrThemeMode> | — |
resolved | Resolved theme actually applied to <html>. | Signal<'light' | 'dark'> | — |
set(mode) | Switch to a specific mode. | (m: WrThemeMode) => void | — |
toggle() | Flip light ↔ dark (skips auto). | () => void | — |
provideWrTheme(config?) | Configure defaultMode, storageKey, attribute name. | (config?) => EnvironmentProviders | — |
wrThemePrePaintScript(options?) | Source of the blocking script that resolves the theme before first paint. Takes the same attribute / storageKey / defaultMode you passed provideWrTheme(), plus storagePrefix and json from provideWrStorage(). | (options?: WrThemePrePaintOptions) => string | — |