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.

Show 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.

Show 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.

Show 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.

Show 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.

Show 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.

Show 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
@* Cycle mode: click rotates Light -> Dark -> Auto *@
<ThemeToggle ToggleStyle="ToggleStyle.Cycle" ShowLabel="true" />

Menu mode

Click opens a popover with Light, Dark, and Auto options. The active mode is highlighted with a checkmark.

Show code
@* Menu mode: popover with Light / Dark / Auto options *@
<ThemeToggle ToggleStyle="ToggleStyle.Menu" 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.

An unhandled error has occurred. Reload Dismiss