Theming & dark mode

Bootstrap 5.3 switches colours with <html data-bs-theme>. The theme store in @mintplayer/ui-core/theming owns the user's choice, persists it in the bs-theme-mode cookie and resolves auto from prefers-color-scheme, live. BsThemeToggle and useBsTheme() are two views of that one store, shared with every other framework's adapter on the page.

Toggle

Auto themeDefault: auto, light, dark
Licht themaCustom modes: light and dark, in Dutch

The button's name is the next action (“Switch to dark theme”), its description is the current state, and each click is announced. Enter and Space cycle it. More than about three modes wants a menu (a radio group) rather than a cycling button.

From code: useBsTheme()

Mode auto, effective light.

<script setup lang="ts">import {  BS_THEME_DEFAULT_MODES,  BsThemeToggle,  useBsTheme,} from '@mintplayer/vue-bootstrap/theming';import { installBootstrap } from '@mintplayer/ui-core/bootstrap';import { bootstrapButtonGroupPage } from '@mintplayer/ui-core/bootstrap/button-group';// Raw Bootstrap markup on this page: it installs the verbatim parts itself, never globally (#452).installBootstrap(bootstrapButtonGroupPage);// Custom modes: any subset, order, or extra entry. Localize by spreading// the defaults and overriding label (the NEXT action) and announcement// (the CURRENT state).const modes = BS_THEME_DEFAULT_MODES  .filter((m) => m.mode !== 'auto')  .map((m) => ({ ...m, label: t(m.label), announcement: t(m.announcement) }));// The same state from code, as readonly refs. They update on any change:// a toggle, another tab, or the OS preference while in auto.const { mode, effectiveMode, setMode } = useBsTheme();</script><template>  <!-- Default cycle: auto -> light -> dark. It drives the shared store itself. -->  <BsThemeToggle />  <BsThemeToggle :modes="modes" aria-describedby="theme-help" />  <button type="button" @click="setMode('dark')">    {{ mode }} ({{ effectiveMode }})  </button></template>

No flash on load

The pre-boot script is generated from the same core as the store (ES5, under 1 KB). It runs before the stylesheets, so a dark user never sees a light frame: with or without SSR, and on cached or prerendered pages. The color-scheme meta themes native controls and scrollbars; the two theme-color metas tint the browser chrome.

<head>  <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">  <!-- The site default: auto, light, dark or a custom mode. -->  <meta name="bs-theme-default-mode" content="auto">  <!-- Blocking, BEFORE the stylesheets: applies the cookie (or the default,       or the OS scheme) to <html data-bs-theme> before the first paint.       Copy @mintplayer/ui-core/theming/bs-theme-preboot.js to /theming/. -->  <script src="/theming/bs-theme-preboot.js"></script>  <link rel="stylesheet" href="/src/styles.css"></head>

Stock bootstrap.min.css has no --mp-color-mode token, which the components' dark select carets and switch knobs query through shadow roots. Import it beside Bootstrap:

@import 'bootstrap/dist/css/bootstrap.min.css';/* --mp-color-mode: the token the components' dark icons query. */@import '@mintplayer/ui-core/theming/color-mode.css';/* A custom theme declares its own token next to its colours. */[data-bs-theme=sepia] {  --mp-color-mode: light;  --bs-body-bg: #f4ecd8;}

Server-side rendering

Splice the attribute onto <html> on the server so even a visitor with JavaScript disabled gets their theme. A valid cookie wins (an explicit auto included), then the meta default, then auto, which renders no attribute. Invalid cookie values are ignored, so the result is always safe to splice.

import { injectThemeAttribute, resolveServerTheme } from '@mintplayer/vue-bootstrap/theming';// Keep defaultMode equal to the bs-theme-default-mode meta// (this demo reads it from the template with a regex).const mode = resolveServerTheme(req.headers.cookie, { defaultMode: 'auto' });const html = injectThemeAttribute(template.replace('<!--app-html-->', appHtml), mode);// The markup depends on the cookie: never let a shared cache mix visitors.res.status(200).set({ 'Content-Type': 'text/html', Vary: 'Cookie' }).end(html);

Cookie

bs-theme-mode: Path=/, SameSite=Lax, one year, Secure on https. Values match ^[a-z0-9-]{1,32}$; localStorage is never used.

import { configureBsTheme } from '@mintplayer/vue-bootstrap/theming';// Before the first toggle or useBsTheme() call: share the cookie across subdomains.configureBsTheme({ cookieDomain: '.example.com' });