Theming & dark mode

Bootstrap 5.3 ships a color-mode system based on the data-bs-theme attribute. @mintplayer/ng-bootstrap adds what it leaves out: the user's choice is kept in a cookie, a tiny pre-boot script applies it before the first paint, the server renders it into the HTML, and a toggle and a signal-based service change it.

Three steps, all in your app shell.

The pre-boot script sets <html data-bs-theme> synchronously from the bs-theme-mode cookie, the site default or prefers-color-scheme, before the browser has resolved any CSS. That is what keeps a dark user from seeing a light flash on reload, on prerendered and cached pages as well as on server-rendered ones. Place it in <head>, after the bs-theme-default-mode meta it reads and before every stylesheet. It is a plain blocking script on purpose: do not add async, defer or type="module".

  
    
      
        <!-- src/index.html: in <head>, BEFORE any stylesheet -->
        
    
      
        <meta name="color-scheme" content="light dark">
        
    
      
        <meta name="theme-color" media="(prefers-color-scheme: light)" content="#ffffff">
        
    
      
        <meta name="theme-color" media="(prefers-color-scheme: dark)" content="#212529">
        
    
      
        
        
    
      
        <!-- Optional site default: auto (the default), light, dark or a custom variant -->
        
    
      
        <meta name="bs-theme-default-mode" content="auto">
        
    
      
        <!-- Blocking on purpose: it must set data-bs-theme before the styles apply -->
        
    
      
        <script src="theming/bs-theme-preboot.js"></script>
        
    

<meta name="color-scheme" content="light dark"> lets the browser draw native controls and scrollbars in either scheme. The two theme-color metas colour the browser's own chrome (the mobile address bar, the installed-app title bar) to match the OS scheme.

Copying the pre-boot script

The script ships once, from @mintplayer/ui-core/theming/bs-theme-preboot.js: there is no copy in @mintplayer/ng-bootstrap, so an input of node_modules/@mintplayer/ng-bootstrap/theming (the path issue #420 originally proposed) finds nothing. It is generated from the same cookie and resolve helpers the library uses, so it cannot drift from them, and it stays under 1 KB. Copy it into your build output with an assets glob, so it is served at /theming/bs-theme-preboot.js.

  
    
      
        // angular.json (or project.json): architect.build.options
        
    
      
        "assets": [
        
    
      
          {
        
    
      
            "glob": "bs-theme-preboot.js",
        
    
      
            "input": "node_modules/@mintplayer/ui-core/theming",
        
    
      
            "output": "theming"
        
    
      
          }
        
    
      
        ]
        
    

Its file name carries no content hash, so do not serve it with a long max-age: a cached copy would outlive a library upgrade. This demo's server.ts serves it with max-age=0.

Turn off critical-CSS inlining

In a production build Angular inlines the CSS it judges critical for the static index.html and loads the rest asynchronously. That file has no data-bs-theme attribute, so the inliner prunes every [data-bs-theme=dark] rule, and a dark user paints light until the async stylesheet arrives. Angular's CSS minifier strips the comments that would keep those rules, so the fix is to switch the inliner off.

  
    
      
        // angular.json (or project.json): architect.build.configurations.production
        
    
      
        "optimization": {
        
    
      
          "scripts": true,
        
    
      
          "styles": { "minify": true, "inlineCritical": false },
        
    
      
          "fonts": true
        
    
      
        }
        
    

The theme toggle

<bs-theme-toggle> is a single button that cycles through its modes. Its accessible name is the next action ("Switch to dark theme"), its description is the current mode ("Light theme"), and each click is announced to screen readers. All the toggles on this page drive the same document theme, so clicking one updates the others.

Live demo

Default

Auto theme

Localized (Dutch)

Automatisch thema

Four modes, with sepia

Auto theme

mode: auto   effectiveMode: light

Default

With no inputs the toggle cycles auto, light, dark (BS_THEME_DEFAULT_MODES). It talks to the theme store directly, so there is nothing to wire up, and BsThemeService follows every click.

  
    
      
        import { BsThemeToggleComponent } from '@mintplayer/ng-bootstrap/theming';
        
    
      
        
        
    
      
        @Component({
        
    
      
          imports: [BsThemeToggleComponent],
        
    
      
          template: `<bs-theme-toggle />`,
        
    
      
        })
        
    
      
        export class NavbarComponent {}
        
    

Localized

[modes] is the cycle, in order. Each entry has a mode, a label (the name while it is next), an announcement (the description while it is current) and an icon. To translate the toggle, spread the defaults and override the two strings, so the modes and icons stay the library's.

  
    
      
        import { BS_THEME_DEFAULT_MODES, type BsThemeToggleMode } from '@mintplayer/ng-bootstrap/theming';
        
    
      
        
        
    
      
        const DUTCH: Record<string, Pick<BsThemeToggleMode, 'label' | 'announcement'>> = {
        
    
      
          auto:  { label: 'Schakel naar automatisch thema', announcement: 'Automatisch thema' },
        
    
      
          light: { label: 'Schakel naar licht thema',       announcement: 'Licht thema' },
        
    
      
          dark:  { label: 'Schakel naar donker thema',      announcement: 'Donker thema' },
        
    
      
        };
        
    
      
        
        
    
      
        // Keep the modes and icons, override only the strings.
        
    
      
        readonly modes = BS_THEME_DEFAULT_MODES.map((m) => ({ ...m, ...DUTCH[m.mode] }));
        
    
      
        
        
    
      
        // <bs-theme-toggle [modes]="modes" />
        
    

More modes

Add an entry per custom variant. icon is SVG path data for a 16×16 viewBox (a string, or an array of strings for several paths). Entries whose mode is not a valid mode are dropped with a warning. If the current mode is not in the list, the toggle shows the first entry and moves to the second.

  
    
      
        import { BS_THEME_DEFAULT_MODES, type BsThemeToggleMode } from '@mintplayer/ng-bootstrap/theming';
        
    
      
        
        
    
      
        // The cycle order is the array order: auto, light, dark, sepia, auto, ...
        
    
      
        readonly modes: BsThemeToggleMode[] = [
        
    
      
          ...BS_THEME_DEFAULT_MODES,
        
    
      
          {
        
    
      
            mode: 'sepia',
        
    
      
            label: 'Switch to sepia theme',  // the button's name while sepia is NEXT
        
    
      
            announcement: 'Sepia theme',     // its description while sepia is CURRENT
        
    
      
            icon: 'M8 1.783C7.015.936 ...',  // SVG path data for a 16x16 viewBox
        
    
      
          },
        
    
      
        ];
        
    
      
        
        
    
      
        // <bs-theme-toggle [modes]="modes" />
        
    
More than three modes? Consider a menu. A cycling button makes the user click through every mode to reach the one they want, and they cannot see the choices up front. Past about three modes, a menu or radio group that calls BsThemeService.setMode() serves them better.

Switching mode from code

BsThemeService exposes the mode as signals. auto resolves to light or dark via prefers-color-scheme and follows the OS live; explicit and custom modes stick. A change in one tab re-themes your other open tabs.

Live demo

mode: auto   effectiveMode: light
  
    
      
        import { Component, inject } from '@angular/core';
        
    
      
        import { BsThemeService } from '@mintplayer/ng-bootstrap/theming';
        
    
      
        
        
    
      
        @Component({ /* ... */ })
        
    
      
        export class AppComponent {
        
    
      
          private readonly theme = inject(BsThemeService);
        
    
      
        
        
    
      
          toggleDark()   { this.theme.setMode('dark'); }
        
    
      
          toggleLight()  { this.theme.setMode('light'); }
        
    
      
          followSystem() { this.theme.setMode('auto'); }
        
    
      
        
        
    
      
          // Read state reactively:
        
    
      
          mode = this.theme.mode;               // 'auto' | 'light' | 'dark' | string
        
    
      
          effective = this.theme.effectiveMode; // 'light' | 'dark' | string (auto resolved)
        
    
      
        }
        
    

The choice lives in a first-party cookie rather than in localStorage, because the server has to see it to render the right theme. The pre-boot script, the store and the server all read the same cookie. With the mode you have now, it reads:

bs-theme-mode=auto; Path=/; SameSite=Lax; Max-Age=31536000

AttributeValue
Namebs-theme-mode (BS_THEME_COOKIE_NAME)
Path/
SameSiteLax
Max-AgeOne year, renewed on every change
SecureOnly when the page is served over https:
DomainOnly when you pass cookieDomain (below)
HttpOnlyNo: the pre-boot script and the store read it in the page

A value must be 1 to 32 characters of a-z, 0-9 and -; it is matched case-insensitively and lower-cased on read. Anything else counts as no cookie at all. This is a security rule, not a style rule: the server writes the value into the HTML, so the check is what keeps a crafted cookie from injecting markup. isValidThemeMode() implements it.

To share one choice across subdomains, set the cookie's Domain with provideBsTheme(). The value is passed through exactly as given.

  
    
      
        // app.config.ts
        
    
      
        import { provideBsTheme } from '@mintplayer/ng-bootstrap/theming';
        
    
      
        
        
    
      
        export const appConfig: ApplicationConfig = {
        
    
      
          providers: [
        
    
      
            // Share the choice between www.example.com and app.example.com.
        
    
      
            // Passed through verbatim as the cookie's Domain; never derived.
        
    
      
            provideBsTheme({ cookieDomain: '.example.com' }),
        
    
      
          ],
        
    
      
        };
        
    

The default mode

A site that should start dark declares it once, in the HTML, with <meta name="bs-theme-default-mode" content="dark"> before the pre-boot script. The script, the store and the Angular server all read the same tag, so there is no provider option to keep in sync. An absent or invalid value means auto. The mode is resolved in this order:

  1. A valid bs-theme-mode cookie, includingauto: a user who chose to follow the OS keeps following it on a dark-by-default site.
  2. The bs-theme-default-mode meta.
  3. auto.

Server-side rendering

With Angular SSR there is no server code to write. On the server, BsThemeService reads the request's cookie and the page's default-mode meta, and renders <html data-bs-theme="dark"> for an explicit mode, so the first byte of HTML is already themed. For auto it renders no attribute, and the pre-boot script resolves it in the browser. Prerendered pages have no request, so they are themed by the pre-boot script too.

The service does this in its constructor, so it has to exist on every request. provideBsTheme() constructs it eagerly, and so does <bs-theme-toggle>. Add the provider so that pages without a toggle are covered as well:

  
    
      
        // app.config.ts
        
    
      
        import { provideBsTheme } from '@mintplayer/ng-bootstrap/theming';
        
    
      
        
        
    
      
        export const appConfig: ApplicationConfig = {
        
    
      
          providers: [
        
    
      
            // Renders <html data-bs-theme> from the request cookie on every page.
        
    
      
            provideBsTheme(),
        
    
      
          ],
        
    
      
        };
        
    
Send Vary: Cookie with rendered HTML. The HTML now differs per cookie. Without the header, a CDN or reverse proxy that caches the page serves one user's theme to everyone.
  
    
      
        // server.ts: in the branch that returns the rendered text/html response
        
    
      
        const headers = new Headers(response.headers);
        
    
      
        headers.append('Vary', 'Cookie');
        
    
      
        return writeResponseToNodeResponse(new Response(body, { status: response.status, headers }), res);
        
    

Custom variants beyond light & dark

The mode string is open-ended. Author a [data-bs-theme="..."] block in your global styles, then activate it with a toggle entry or setMode('your-variant'). The sepia variant used on this page lives in the demo's styles.scss; try it with the four-mode toggle above or the Sepia button.

  
    
      
        /* your-app/src/styles.scss: author a custom variant once, globally */
        
    
      
        [data-bs-theme="sepia"] {
        
    
      
          /* Required: tells the library's components (select caret, switch knob, ...)
        
    
      
             whether this variant is light or dark. */
        
    
      
          --mp-color-mode: light;
        
    
      
          color-scheme: light;
        
    
      
        
        
    
      
          --bs-body-bg:          #f4ecd8;
        
    
      
          --bs-body-color:       #5b4636;
        
    
      
          --bs-emphasis-color:   #3b2a1a;
        
    
      
          --bs-link-color:       #8b5a2b;
        
    
      
          --bs-link-hover-color: #5b4636;
        
    
      
          --bs-border-color:     #d8c9a5;
        
    
      
          /* Override any --bs-* variable Bootstrap defines under [data-bs-theme="light"]. */
        
    
      
        }
        
    
      
        
        
    
      
        // Then activate it from anywhere: a toggle entry, or the service.
        
    
      
        inject(BsThemeService).setMode('sepia');
        
    
Every custom variant must declare --mp-color-mode. Set it to light or dark. The library's web components cannot see Bootstrap's [data-bs-theme=dark] rules through their encapsulation, so they read this token to pick their light or dark icons (the select caret, the switch knob). Without it they fall back to light. Browser support. Those icons switch through a CSS style query (@container style(--mp-color-mode: dark)), measured working in Chromium 151, Firefox 153 and WebKit 26.5. An engine without custom-property style queries keeps the light caret and knob on a dark page. Everything else (colours, borders, backgrounds) follows the theme regardless. auto only resolves to light or dark.prefers-color-scheme doesn't know about custom variants, so they must be set explicitly. Switching to auto while the user is on a custom variant returns them to the system preference.

Build-time customization (SCSS)

Bootstrap's design tokens are SCSS variables. Override them before importing the library's SCSS bundle to bake your palette into the compiled CSS, with no runtime cost. This is the right approach when your brand colors are fixed.

  
    
      
        // your-app/src/styles.scss
        
    
      
        // Override Bootstrap SCSS variables BEFORE importing the library's bundle.
        
    
      
        
        
    
      
        $primary:   #ff5722;
        
    
      
        $body-bg:   #fafafa;
        
    
      
        $body-color: #212529;
        
    
      
        
        
    
      
        // Then import the library's compiled stylesheet entry.
        
    
      
        @import '@mintplayer/ng-bootstrap/bootstrap.scss';
        
    

All standard Bootstrap variables ($primary, $body-bg, $navbar-padding-y, …) are available. See the Bootstrap Sass reference for the full list.

Runtime customization (CSS variables)

Bootstrap also exposes most tokens as --bs-* CSS custom properties. Mutating them at runtime lets you build features like a per-user theme picker without re-deploying. The components pick up the change on the next paint.

Live demo

What changes when you Apply: every component below uses --bs-primary: the swatch, the buttons, the badge, the link and the bar all swap in lockstep with the CSS variable.

swatch
badgelink-primary
  
    
      
        // Set any --bs-* custom property on the root element. Bootstrap reads them
        
    
      
        // live: components re-style on the next browser repaint, no rebuild required.
        
    
      
        
        
    
      
        document.documentElement.style.setProperty('--bs-primary', '#ff5722');
        
    
      
        document.documentElement.style.setProperty('--bs-primary-rgb', '255, 87, 34');
        
    
      
        
        
    
      
        // To revert:
        
    
      
        document.documentElement.style.removeProperty('--bs-primary');
        
    
      
        document.documentElement.style.removeProperty('--bs-primary-rgb');
        
    
Note about .btn-primary and .btn-outline-primary. Vanilla Bootstrap 5.3 bakes literal color values into each button variant's scoped --bs-btn-* variables, so mutating --bs-primary at runtime does not reach buttons in a stock Bootstrap project. @mintplayer/ng-bootstrap ships a small override that re-binds .btn-primary and .btn-outline-primary to var(--bs-primary) (with color-mix() for hover / active shades), which is why the buttons above swap with the rest. If you want the same behavior for .btn-success, .btn-danger, etc., add an analogous block in your own stylesheet.

Per-component variables

Each Bootstrap component also defines its own scoped --bs-* variables. Tweaking these lets you restyle one component without touching the global palette. A few common ones:

ComponentVariableEffect
Navbar--bs-navbar-colorDefault link/text color
Navbar--bs-navbar-active-colorActive link color
Navbar--bs-navbar-brand-colorBrand text color
Card--bs-card-bgCard surface color
Card--bs-card-border-colorCard border
Card--bs-card-cap-bgCard header / footer surface
Dropdown--bs-dropdown-bgDropdown menu surface
Dropdown--bs-dropdown-link-hover-bgHovered item background
Modal--bs-modal-bgModal surface
Modal--bs-modal-header-border-colorHeader separator
Button--bs-btn-bgButton surface (scope to a selector)
Button--bs-btn-border-colorButton border
Body / global--bs-body-bgPage background
Body / global--bs-body-colorDefault text color
Body / global--bs-border-colorDefault border color
Body / global--bs-primary  /  --bs-primary-rgbPrimary brand color (pair both for opacity-aware usage)

The canonical list is component-specific and may shift between Bootstrap versions. See the CSS variables reference for the authoritative table.

Adaptive navbar

To make <bs-navbar> follow the page theme automatically, pass [color]="'body-tertiary'". The navbar emits bg-body-tertiary (no data-bs-theme override) and Bootstrap's --bs-tertiary-bg CSS variable swaps with the page theme, giving a slightly tinted, theme-aware navbar in both light and dark.

  
    
      
        <!-- Pass a Bootstrap utility suffix as a string. Result on the rendered
        
    
      
             <nav>: class="… bg-body-tertiary" with no [data-bs-theme] override,
        
    
      
             so it inherits the page theme from <html data-bs-theme>. -->
        
    
      
        <bs-navbar [color]="'body-tertiary'" [breakpoint]="'lg'">
        
    
      
          <!-- navbar content -->
        
    
      
        </bs-navbar>
        
    

[color] accepts either a Color enum value (Color.dark, Color.primary, …) or a string utility-class suffix ('body-tertiary', 'body-secondary'). Enum values also pin the navbar's own theme via data-bs-theme; string suffixes let the page theme cascade through. null emits no background: a fully transparent navbar that inherits everything from the page.

API reference

Everything is exported from @mintplayer/ng-bootstrap/theming. The framework-neutral parts come from @mintplayer/ui-core/theming and are re-exported, so one import is enough.

ExportKindDescription
BS_THEME_COOKIE_NAMEconst'bs-theme-mode', the cookie that persists the mode.
BS_THEME_DEFAULT_MODE_METAconst'bs-theme-default-mode', the name of the meta tag that declares the site default.
isValidThemeMode(value)functionTrue for 1 to 32 characters of a-z, 0-9 and "-" (case-insensitive). Every mode that reaches markup passes this check.
readThemeCookie(cookieString)functionThe lower-cased mode from a Cookie header or document.cookie, or null when it is absent or invalid.
resolveServerTheme(cookieHeader, { defaultMode })functionThe data-bs-theme value a server should render: cookie, then default, then auto. null means auto (render no attribute).
injectThemeAttribute(html, mode)functionSets data-bs-theme on the <html> open tag of a markup string, keeping its other attributes. Idempotent; null leaves the markup unchanged.
BsThemeServiceservicemode and effectiveMode signals plus setMode(). A mirror of the document theme store; on the server it renders the request cookie.
provideBsTheme({ cookieDomain })providerSets the cookie Domain, so a choice made on one subdomain applies on its siblings. Optional.
BsThemeToggleComponentcomponent<bs-theme-toggle>: a button that cycles through [modes]. Its name is the next action, its description the current mode.
BS_THEME_DEFAULT_MODESconstThe default [modes] cycle: auto, light, dark. Spread it to localize or extend.