Foundations
Dark Mode and Theming system.
Three-mode theme system (Light, Dark, Auto) with 200+ CSS design tokens, FOUC prevention, and a ready-made toggle component. ThemeMode is the user's selection; ResolvedTheme is what's actually applied.
Overview
The theme system supports three modes: Light, Dark, and Auto. Auto follows the operating system's prefers-color-scheme media query. The user's selection is stored as ThemeMode; the effective visual appearance is tracked as ResolvedTheme.
Both values are combined into a ThemeState record that is cascaded through ThemeProvider so any descendant component can react to theme changes.
| Concept | Type | Description |
|---|---|---|
| ThemeMode | enum | User selection: Light, Dark, or Auto (follows OS) |
| ResolvedTheme | enum | Effective appearance actually applied: Light or Dark |
| ThemeState | record | Immutable record combining Mode + ResolvedTheme, cascaded by ThemeProvider |
Setup Guide
Four steps to enable theming in a consuming app. Each step adds one piece of the pipeline.
Step 1: Register services in DI
AddSilliumCoreBlazor registers IThemeService as a scoped service alongside localization and other core services.
Single registration
No separate AddTheme call needed. IThemeService is included automatically in AddSilliumCoreBlazor.
Show code
Copy code
// Program.cs
builder.Services.AddSilliumCoreBlazor(options =>
{
options.SupportedCultures = ["en", "de"];
options.DefaultCulture = "en";
});
// AddSilliumCoreBlazor registers IThemeService (scoped)
// along with localization, CultureService, and other services.Step 2: Add ThemeHeadScript to <head>
ThemeHeadScript renders a synchronous script in the document head that reads localStorage and applies the .dark class before the first paint.
Must be in <head>
Place ThemeHeadScript inside the <head> element so it executes before any body content renders. This prevents FOUC (flash of unstyled content).
Show code
Copy code
@* App.razor — inside <head> *@
<head>
...
<ThemeHeadScript />
</head>
@* ThemeHeadScript renders a synchronous <script> that reads
localStorage and applies the .dark class before first paint,
preventing any flash of unstyled content (FOUC). *@Step 3: Wrap content with ThemeProvider
ThemeProvider cascades ThemeState to all descendants, manages JS interop for .dark class toggling, and listens for OS-level prefers-color-scheme changes.
Cascading value
ThemeProvider cascades ThemeState as a non-fixed CascadingValue. Any child component can consume [CascadingParameter] ThemeState to react to theme changes.
Show code
Copy code
@* MainLayout.razor *@
@inject IThemeService ThemeService
<ThemeProvider>
<div class="app-shell">
<AppBar>
<ThemeToggle />
</AppBar>
@Body
</div>
</ThemeProvider>
@* ThemeProvider cascades ThemeState to all descendants,
manages JS interop for class toggling, and listens for
OS-level prefers-color-scheme changes in Auto mode. *@Step 4: Add ThemeToggle for user control
ThemeToggle provides the UI for switching between Light, Dark, and Auto modes. Supports two interaction styles: Cycle and Menu.
Ready to use
ThemeToggle works out of the box. It injects IThemeService internally and persists the selection to localStorage via ThemeProvider.
Show code
Copy code
@* Cycle mode — click rotates Light -> Dark -> Auto *@
<ThemeToggle ToggleStyle="ToggleStyle.Cycle" ShowLabel="true" />
@* Menu mode — popover with all three options *@
<ThemeToggle ToggleStyle="ToggleStyle.Menu" ShowLabel="true" />
@* Customization *@
<ThemeToggle Color="Color.Neutral"
Variant="Variant.Outlined"
Size="Size.Small" />IThemeService API
IThemeService is the central service for reading and updating theme state. It is registered as scoped and injected by ThemeProvider, ThemeToggle, and any custom component that needs theme awareness.
| Member | Type | Description |
|---|---|---|
| State | ThemeState | Current ThemeState record containing Mode (user selection) and ResolvedTheme (effective appearance). |
| SetMode(ThemeMode) | void | Update the user's theme selection. Fires OnChanged. ThemeProvider will persist and apply the change via JS interop. |
| SetResolvedTheme(ResolvedTheme) | void | Update the effective theme (Light or Dark). Typically called by ThemeProvider when Auto mode resolves from the OS preference. |
| OnChanged | event Action? | Fires whenever State changes. Subscribe to trigger re-renders in custom components. |
CSS Design Token Families
The theme system is built on 200+ CSS custom properties organized into families. Light mode values are defined on :root; dark mode overrides are scoped to the .dark class. Tailwind aliases map --color-app-* to var(--ui-color-*) for utility class usage.
| Token Family | Count | Examples |
|---|---|---|
| Colors | 23 | --ui-color-bg, --ui-color-ink, --ui-color-primary, --ui-color-accent, --ui-color-border |
| Elevation | 11 | --ui-elevation-0 ... --ui-elevation-10 |
| Typography | 12 | --ui-typo-h1 ... --ui-typo-h6, --ui-typo-body, --ui-typo-small, --ui-typo-caption |
| Spacing | 8 | --ui-space-2xs ... --ui-space-3xl |
| Transitions | 4 | --ui-transition-fast, --ui-transition-default, --ui-transition-slow, --ui-ease-default |
| Icons | 4 | --ui-icon-weight, --ui-icon-grade, --ui-icon-opsz, --ui-icon-fill |
| Borders | 3 | --ui-border-thin, --ui-border-default, --ui-border-thick |
| Opacity | 3 | --ui-opacity-disabled, --ui-opacity-overlay, --ui-opacity-muted |
Token usage and dark mode override
Light tokens are defined on :root, dark overrides on .dark, and Tailwind aliases bridge them to utility classes.
Tailwind integration
Use Tailwind utilities like bg-app-bg, text-app-ink, border-app-border in your markup. They resolve to the active theme's CSS tokens automatically.
Show code
Copy code
/* :root defines light mode tokens */
:root {
--ui-color-bg: #f5efe3;
--ui-color-ink: #1f2937;
--ui-color-primary: #f0b429;
--ui-color-accent: #f0b429;
--ui-color-border: #e6d7bc;
/* ... 23 color tokens total */
}
/* .dark overrides for dark mode */
.dark {
--ui-color-bg: #161a20;
--ui-color-ink: #eef2f8;
--ui-color-primary: #79a8ff;
--ui-color-accent: #79a8ff;
--ui-color-border: #3b4656;
}
/* Tailwind aliases via --color-app-* */
@theme {
--color-app-bg: var(--ui-color-bg);
--color-app-ink: var(--ui-color-ink);
--color-app-accent: var(--ui-color-accent);
--color-app-border: var(--ui-color-border);
}
/* Usage in components */
.my-card {
background: var(--ui-color-panel);
color: var(--ui-color-ink);
border: var(--ui-border-thin) solid var(--ui-color-border);
box-shadow: var(--ui-elevation-2);
transition: box-shadow var(--ui-transition-default) var(--ui-ease-default);
}FOUC Prevention
ThemeHeadScript renders a synchronous JavaScript snippet inside the <head> element. It reads the stored theme preference from localStorage and applies the .dark CSS class to the <html> element before the browser paints any content. This eliminates the flash of wrong-themed content that would occur if the class were applied after Blazor initializes.
FOUC prevention script
The ThemeHeadScript component and the inline JS it references.
Synchronous by design
The script is intentionally synchronous (no async/defer) so it blocks rendering until the correct theme class is applied. This is a tiny script (~200 bytes) and does not impact performance.
Show code
Copy code
@* App.razor — ThemeHeadScript generates this inline script: *@
<script src="_content/Sillium.Core.Components/sillium-theme-init.js"
data-storage-key="sillium-theme"
data-dark-class="dark"></script>
/* sillium-theme-init.js (synchronous, runs before paint) */
(function () {
var s = document.currentScript;
var key = (s && s.dataset.storageKey) || "sillium-theme";
var cls = (s && s.dataset.darkClass) || "dark";
var mode;
try { mode = localStorage.getItem(key); } catch (e) {}
var dark = mode === "dark"
|| (mode !== "light"
&& window.matchMedia("(prefers-color-scheme: dark)").matches);
if (dark) document.documentElement.classList.add(cls);
})();ThemeToggle Component
ThemeToggle provides two interaction styles. Cycle mode uses a single icon button that rotates through Light, Dark, and Auto on each click. Menu mode opens a popover with all three options for explicit selection.
| Parameter | Type | Default | Description |
|---|---|---|---|
| ToggleStyle | ToggleStyle | Cycle | Cycle: single-click rotation. Menu: popover with three options. |
| ShowLabel | bool | false | Show a text label next to the icon. |
| Color | Color | Primary | Button color scheme. |
| Variant | Variant | Text | Button variant: Text, Outlined, or Filled. |
| Size | Size | Medium | Button size: Small, Medium, Large, ExtraLarge. |
Cycle mode
Click rotates through Light, Dark, Auto. The icon and label update to reflect the current mode.
Show code
Copy code
@* Cycle mode: click rotates Light -> Dark -> Auto *@
<ThemeToggle ToggleStyle="ToggleStyle.Cycle" ShowLabel="true" />File Reference
All files involved in the theming system, located in the Sillium.Core.Blazor project.
| File | Responsibility |
|---|---|
| Services/IThemeService.cs | Contract: State property, SetMode, SetResolvedTheme, OnChanged event. |
| Services/ThemeService.cs | Default implementation registered by AddSilliumCoreBlazor as scoped. |
| Models/ThemeMode.cs | Enum: Light, Dark, Auto. |
| Models/ThemeState.cs | Sealed record: ThemeState(ThemeMode Mode, ResolvedTheme ResolvedTheme). |
| Models/ResolvedTheme.cs | Enum: Light, Dark. The effective appearance after Auto is resolved. |
| Components/ThemeProvider.razor | Cascading wrapper: JS interop, class toggling, OS preference listener, localStorage persistence. |
| Components/ThemeToggle.razor | User-facing toggle: Cycle and Menu modes with configurable appearance. |
| Components/ThemeHeadScript.razor | Inline <script> for FOUC prevention: reads localStorage, applies .dark class before paint. |
| wwwroot/sillium-core.js | JS module with runtime functions: initTheme, setThemeMode, applyThemeTransition, system listener. |
| wwwroot/sillium-theme-init.js | Synchronous boot script referenced by ThemeHeadScript for immediate class application. |
Theme Color Tokens
Each theme color (Primary, Info, Error, etc.) generates a palette of 11 shades. Variant configurations then reference specific shade steps for background, border, and text — creating the visual appearance of Filled, Outlined, and Text variants.
The table below shows the default shade references for each variant. These can be customized per color in the Theme Editor. For example, a Filled primary button uses shade 300 as background, shade 400 as border, and shade 50 (very light) as text color.
| Variant | Background Shade | Border Shade | Text Shade |
|---|---|---|---|
| Filled | 300 | 400 | 50 |
| Outlined | 100 | 400 | 700 |
| Text | 50 | 200 | 500 |
Each channel (background, border, text) also has an opacity value (0.0 to 1.0). When opacity is less than 1, the color is rendered as a semi-transparent value. This is useful for subtle backgrounds in the Text variant.