Theming
Using bit BlazorUI, you can customize the look and feel of your app using the theme features by specifying
the color of the components, the darkness of the surfaces, the level of shadow, the appropriate opacity of elements, etc.
The default theme is based on the Microsoft Fluent design system. Light and dark variants are bundled, and the entire token set is overridable so you can tailor color, motion, density, typography, and layout to match your product.
At a glance
The theme system has three cooperating layers. Pick the layer that matches the change you want to make:
1. HTML attributes on
2. C# APIs —
3. JavaScript API —
Underneath, every component reads
Quick setup
Register the bit BlazorUI services so
builder.Services.AddBitBlazorUIServices();Then enable system theme tracking on the host document so the app follows the OS automatically on first paint:
<html bit-theme-system bit-theme-persist>...</html>The two attributes work together:
HTML attributes
Every theme behavior can be configured declaratively on the
<!-- Pick a starting preset -->
<html bit-theme="fluent-dark">...</html>
<!-- Use a different default when no preset is set -->
<html bit-theme-default="dark">...</html>
<!-- Follow the operating system (light/dark) -->
<html bit-theme-system>...</html>
<!-- Persist the user's pick in localStorage -->
<html bit-theme-persist>...</html>
<!-- Also mirror the pick into the bit-theme-preference cookie (SSR sync) -->
<html bit-theme-persist bit-theme-persist-cookie>...</html>
<!-- Rename the resolved keys (combine with bit-theme-system) -->
<html bit-theme-system
bit-theme-light="brand-light"
bit-theme-dark="brand-dark">...</html>
<!-- Animate theme swaps (View Transitions API cross-fade) -->
<html bit-theme-system bit-theme-view-transition>...</html>
<!-- Mix and match -->
<html bit-theme-system bit-theme-persist bit-theme-default="fluent-light">...</html>Resolution order (mirrored in the SSR script and
1.
2.
3.
Design tokens (CSS variables)
Tokens are organized into stable families. Each preset writes the same token names from a different palette, so swapping presets re-skins every component without touching component CSS.
Color —
Background, foreground, border —
Shadow —
Shape —
Spacing —
Z-index —
Typography —
Motion —
Layout —
Semantic aliases —
Per-component —
The full source is in the Fluent styles folder on GitHub. Here's the shape of the color tokens:
:root,
:root[bit-theme="light"],
:root[bit-theme="fluent"],
:root[bit-theme="fluent-light"] {
/* primary brand color, with hover/active and dark/light steps */
--bit-clr-pri: #1276C6;
--bit-clr-pri-hover: #0B6AB4;
--bit-clr-pri-active: #0460A5;
--bit-clr-pri-dark: #005391;
--bit-clr-pri-dark-hover: #00487F;
--bit-clr-pri-dark-active: #003E70;
--bit-clr-pri-light: #85C1FF;
--bit-clr-pri-light-hover: #74B4F6;
--bit-clr-pri-light-active: #66A9EE;
--bit-clr-pri-text: #FFFFFF;
--bit-clr-pri-focus: var(--bit-clr-pri);
/* same shape for sec / ter / inf / suc / wrn / swr / err */
/* and for fg-* / bg-* / brd-* surfaces */
}Override any of them in your app stylesheet to reskin the matching theme:
:root[bit-theme="dark"] {
--bit-clr-bg-pri: #060E2D;
--bit-clr-bg-sec: #0A153D;
--bit-clr-bg-pri-hover: #07154A;
--bit-clr-bg-pri-active: #061241;
--bit-clr-fg-sec: #DDDDDD;
}How a color family is built
Every color family — semantic role or neutral surface — uses the same nine slots: three tiers (
Two rules govern the tones. Interaction always moves toward more contrast with the surface it sits on, so states darken in the light palettes and lighten in the dark ones — the direction Fluent 2's own dark hover tokens take. And tones are placed on evenly spaced OKLCH lightness steps at a fixed hue per family, so the steps read as equal-sized to the eye instead of bunching up in the dark end the way sRGB percentages do, and no family drifts in hue across its ramp.
The values are solved against contrast rules rather than picked by eye, and the packaged palettes are gated on them by tests (WCAG 2.2):
•
• The
•
•
Two roles carry a documented exception:
In the dark palettes the page canvas deliberately sits a little off pure black (
Semantic aliases are the intent tier for your app's CSS: purpose-named tokens defined over the primitives, shipped in every bundle regardless of the active theme (they are pure
--bit-sem-surface-page /* → var(--bit-clr-bg-pri) */
--bit-sem-surface-elevated /* → var(--bit-clr-bg-sec) */
--bit-sem-surface-muted /* → var(--bit-clr-bg-ter) */
--bit-sem-text-primary /* → var(--bit-clr-fg-pri) */
--bit-sem-text-secondary /* → var(--bit-clr-fg-sec) */
--bit-sem-border-default /* → var(--bit-clr-brd-pri) */
--bit-sem-accent-primary /* → var(--bit-clr-pri) */
--bit-sem-focus-ring /* → var(--bit-shd-focus-ring) */
--bit-sem-focus-color /* → var(--bit-clr-pri-focus) */The two tiers have deliberately different override semantics. Components consume primitives (and their per-role/per-component variables), never the aliases — so overriding a primitive retunes components and the aliases that point at it, while overriding a semantic token retunes only the app styling that opted into that intent. This holds for every override path: CSS custom properties substitute
var theme = new BitTheme();
// Give elevated app surfaces a subtle brand tint - components are unaffected.
theme.Color.Semantic.SurfaceElevated =
BitThemeUtilities.WithAlpha("var(--bit-clr-pri-light)", 0.35);
await BitThemeManager.ApplyBitThemeAsync(theme);SCSS variables. If you author styles in
Per-component variables
Below the theme tokens sits a third tier: every component exposes its own themable knobs as
Override them app-wide from your CSS, or per instance via the component's
/* app-wide: every primary button gets a custom fill and padding */
.bit-btn-pri {
--bit-btn-clr: #6C2BD9;
--bit-btn-padding: 0.75rem 1.5rem;
}<!-- one instance: no theme, no stylesheet, just the knob -->
<BitButton Style="--bit-btn-clr: #6C2BD9">Brandy</BitButton>Presets
public static class BitThemePresets
{
public const string Light = "light";
public const string Dark = "dark";
public const string Fluent = "fluent"; // alias for fluent-light
public const string FluentLight = "fluent-light";
public const string FluentDark = "fluent-dark";
public const string System = "system"; // pseudo-preset: follow OS
}For type-safety, the
// Built-in factories
await _bitThemeManager.SetThemeAsync(BitThemeName.FluentDark);
await _bitThemeManager.SetThemeAsync(BitThemeName.System);
// Custom theme name (matches your CSS selector)
var corporate = BitThemeName.Custom("acme-dark");
await _bitThemeManager.SetThemeAsync(corporate);Scoped presets
The
<!-- The page follows the user's theme; this section is always dark -->
<section bit-theme="dark">
<BitCard>
<BitText Typography="BitTypography.H5">Always-dark hero</BitText>
<BitButton>Call to action</BitButton>
</BitCard>
</section>Scoped presets and
Pair a preset with
// Sample accents (hex)
BitAccentColorPresets.Blue; // #1276C6 - the packaged palette's own primary
BitAccentColorPresets.Purple; // #8764B8
BitAccentColorPresets.Green; // #107C10
BitAccentColorPresets.Orange; // #CA5010
BitAccentColorPresets.Teal; // #038387
BitAccentColorPresets.Rose; // #C239B3Feeding
BitThemeManager
[Inject] private BitThemeManager BitThemeManager { get; set; } = default!;Switching presets
// Set a packaged preset; updates the bit-theme attribute on <html>.
await BitThemeManager.SetThemeAsync(BitThemePresets.FluentDark);
// Toggle between configured light / dark names.
var newName = await BitThemeManager.ToggleDarkLightAsync();
// Read the active name (null when JS interop is unavailable, e.g. prerender).
var current = await BitThemeManager.GetCurrentThemeAsync();
// Read what's persisted in localStorage (returns "system" when following OS).
var stored = await BitThemeManager.GetCurrentPersistedThemeAsync();
// Re-follow the OS even after the user picked an explicit preset.
await BitThemeManager.SetThemeAsync(BitThemePresets.System);
// Detect OS appearance directly.
var systemIsDark = await BitThemeManager.IsSystemInDarkModeAsync();Applying token overrides
var brand = new BitTheme
{
Color =
{
Primary = { Main = "#7C3AED", Text = "#FFFFFF" },
Background = { Primary = "#FAF5FF" },
Foreground = { Primary = "#1E1B4B" },
},
Shape = { BorderRadius = "0.5rem" },
Motion = { Duration = "180ms" },
};
await BitThemeManager.ApplyBitThemeAsync(brand);
// Scope the same overrides to a single element instead of the body.
await BitThemeManager.ApplyBitThemeAsync(brand, myCardElement);
// Remove only the variables ApplyBitThemeAsync set on the target element.
await BitThemeManager.ClearBitThemeOverridesAsync();
await BitThemeManager.ClearBitThemeOverridesAsync(myCardElement);Precedence cheatsheet
// 1. Pick the base palette
await BitThemeManager.SetThemeAsync(BitThemePresets.FluentDark);
// 2. Override a couple of brand tokens globally
await BitThemeManager.ApplyBitThemeAsync(new BitTheme
{
Color = { Primary = { Main = "#7C3AED" } }
});
// 3. (optional) Override the same tokens for a region using BitThemeProviderBitThemeProvider
<BitThemeProvider Theme="purpleTheme">
<BitButton>Purple action</BitButton>
<BitCheckbox Label="Notify me" />
</BitThemeProvider>
@code {
BitTheme purpleTheme = new()
{
Color = { Primary = { Main = "#7C3AED", Text = "#FFFFFF" } },
Shape = { BorderRadius = "0.5rem" },
};
}Read the merged theme inside any descendant via
[CascadingParameter] public BitTheme? Theme { get; set; }Providers nest, and inner overrides win. Missing values cascade through the parent (same rules as
<BitThemeProvider Theme="brandTheme">
<BitButton>Default brand</BitButton>
<BitThemeProvider Theme="dangerOverlay">
<BitButton>Destructive</BitButton>
</BitThemeProvider>
</BitThemeProvider>
@code {
BitTheme brandTheme = new()
{
Color = { Primary = { Main = "#1276C6" } }
};
BitTheme dangerOverlay = new()
{
Color = { Primary = { Main = "#C50F1F" } } // overrides only Primary.Main
};
}Set
<BitThemeProvider Theme="cardTheme"
RootElement="section"
class="brand-region"
data-tenant="acme">
<BitText Typography="BitTypography.H5">Acme dashboard</BitText>
<BitButton>Primary action</BitButton>
</BitThemeProvider>Need a named cascade so multiple providers can coexist? Set
Performance: the
<BitThemeProvider Theme="_stableTheme" Frozen="true">
<HeavySubtree />
</BitThemeProvider>Theme change notifications (.NET)
@inject BitThemeNotifications ThemeNotifications
@implements IDisposable
protected override void OnInitialized()
{
// Subscribing is enough - it registers the JS↔.NET notifier automatically,
// no BitThemeManager call is needed first.
ThemeNotifications.ThemeChanged += OnThemeChanged;
}
private void OnThemeChanged(object? sender, BitThemeChangedEventArgs e)
{
if (!e.HasNewTheme) return;
// Marshal back to the Blazor sync context before touching component state.
InvokeAsync(() =>
{
Console.WriteLine($"theme: {e.OldTheme} -> {e.NewTheme}");
StateHasChanged();
});
}
public void Dispose()
{
ThemeNotifications.ThemeChanged -= OnThemeChanged;
}Always unsubscribe.
Reduce theme flash (BitThemeSsr)
Server-rendered apps that use
Drop the full
<head>
@((MarkupString)BitThemeSsr.InlineHeadScript)
<!-- bit BlazorUI / Fluent stylesheet links AFTER the script -->
<link rel="stylesheet" href="..." />
</head>Need a CSP
@((MarkupString)BitThemeSsr.BuildInlineHeadScript(Context.Items["csp-nonce"]?.ToString()))Or wrap the body yourself when you need full control of script attributes (
<script>@BitThemeSsr.InlineHeadScriptBody</script>The script logic mirrors the runtime
SSR cookie convention
// On the server (Razor / middleware): read the cookie and let the helper build the attributes.
var preference = Context.Request.Cookies[BitThemeCookie.PreferenceCookieName];
// BuildRootThemeAttributes emits bit-theme="…" for a concrete preference (correct first paint),
// or bit-theme-system for "system"/no preference (deferring light/dark to the client script) -
// so you never emit a contradictory bit-theme + bit-theme-system pair by hand.
// In your root document, render the generated attributes on the <html> element:
<html @((MarkupString)BitThemeSsr.BuildRootThemeAttributes(preference)) bit-theme-persist bit-theme-persist-cookie>
// Second argument: the theme to paint when there is no preference yet (or it is "system").
// It rides along as bit-theme-default, so no-JS clients still get a sensible palette.
BitThemeSsr.BuildRootThemeAttributes(preference, defaultTheme: BitThemePresets.FluentLight);Both arguments are normalized (trimmed, lower-cased) and validated against a strict
With
Color derivation & contrast
The fastest path from a brand color to a working theme is
// One brand color → complete light & dark theme overlays.
var light = BitThemeFactory.CreateLightTheme("#7C3AED");
var dark = BitThemeFactory.CreateDarkTheme("#7C3AED");
await BitThemeManager.ApplyBitThemeAsync(isDark ? dark : light);
// Seed more roles when your brand defines them; unseeded
// roles keep the packaged stylesheet defaults.
var themed = BitThemeFactory.CreateLightTheme(new BitThemeAccentColors
{
Primary = "#7C3AED",
Success = "#107C10",
Error = "#C50F1F",
});A whole theme from one color
// One brand color → an entire palette (~200 tokens), not just the accent.
var light = BitThemeFactory.CreateLightThemeFromSeed("#7C3AED");
var dark = BitThemeFactory.CreateDarkThemeFromSeed("#7C3AED");
await BitThemeManager.ApplyBitThemeAsync(isDark ? dark : light);
// Every default is "change nothing beyond the hue rotation"; each knob is a move away from that.
var tuned = BitThemeFactory.CreateLightThemeFromSeed("#7C3AED", new BitThemeSeedOptions
{
NeutralTintChroma = 0.008, // tint the light scheme's grays too (dark is already tinted)
SemanticHarmonizationDegrees = 8, // lean green/amber/red toward the brand (off by default)
SecondaryHueShift = 90, // pin the second accent instead of keeping Fluent's spacing
IncludeNeutrals = false, // accents and status roles only
});The palette is not calculated from step constants — it is this palette, the packaged Fluent one, with a hue rotation applied. That matters because the packaged values are hand-solved against the contrast rules their own stylesheets document, and hand-solved values have deliberate irregularities a fitted curve smooths away: a dark tier that stops early where the on-color is black, a light tier that runs into white. Transforming them keeps those decisions instead of re-deriving past them.
So the accents rotate onto the seed's hue, with primary also shifted in lightness and chroma so its main slot lands exactly on the brand color you passed. Secondary, tertiary and info rotate by the same angle, which preserves Fluent's own spacing between them — the ~157° from primary to secondary, info sitting on primary's hue — without those numbers being written down anywhere. The neutrals rotate too, so the dark palette's already-tinted surfaces land on the new hue; the light palette's are pure gray, and rotation alone leaves them that way until
Harmonization is off by default for two reasons. It is the only setting that can make the result disagree with the packaged palettes when the seed is their own primary. And Material's 15° cap is measured in CAM16 hue rather than OKLCH — ported across unchanged it over-rotates here, pulling the packaged green to a teal and the red to a magenta. Around 6-8° is the useful range in this color space.
Contrast survives the transform. Rotating hue happens at constant OKLCH lightness, which is perceptual rather than photometric, so WCAG ratios do move a little; a repair pass afterwards pulls back any ramp step whose on-color would no longer clear its floor, ending up at the same shallower ramp the packaged palettes give those roles by hand. The floors are the ones those palettes document about themselves — on-color ≥ 4.5:1 over every fill it is painted on, foregrounds ≥ 4.5:1 over all three surfaces,
Seeding
Try it. Each swatch below feeds one
Under the hood,
The
var theme = new BitTheme();
// Derive every primary step from #7C3AED.
BitThemeColorDerivation.FillColorRoleFromMain(
theme.Color.Primary,
mainHex: "#7C3AED");
// The auto-generated Text is set to #000 / #FFF by whichever has the
// higher WCAG contrast against Main (the WCAG-AA-optimal choice).
BitThemeColorDerivation.FillColorRoleFromMain(
theme.Color.Success,
mainHex: "#107C10");
// Dark-scheme derivation is explicit here (BitThemeFactory brightens
// the brand color for you; this overload expects the dark-scheme main).
BitThemeColorDerivation.FillColorRoleFromMain(
theme.Color.Info,
mainHex: "#4FA3F4",
scheme: BitThemeColorScheme.Dark);
await BitThemeManager.ApplyBitThemeAsync(theme);For ad-hoc contrast checks, use
var ratio = BitThemeColorContrast.GetContrastRatio("#FFFFFF", "#1276C6"); // 4.74
bool passesNormal = BitThemeColorContrast.MeetsWcagAaNormalText(ratio); // true
bool passesLarge = BitThemeColorContrast.MeetsWcagAaLargeText(ratio); // trueAPCA contrast (advisory)
The same class also exposes APCA (Advanced Perceptual Contrast Algorithm) — the perceptually-tuned, polarity-aware method that is the candidate for the in-progress WCAG 3. WCAG 2.x overstates contrast for dark color pairs, so it is a poor guide when tuning dark themes; APCA scores dark-text-on-light differently from light-text-on-dark and tracks perceived readability across the whole lightness range. Treat it as advisory — WCAG 2.x remains the formal conformance bar (WCAG 3 is still a Working Draft), which is why the built-in palettes and
// APCA is directional: pass text first, background second.
// Returns a signed Lc (positive = dark text on light bg, negative = the reverse);
// judge readability by the magnitude.
double lc = BitThemeColorContrast.GetApcaContrast(textHex: "#141414", backgroundHex: "#4FA3F4"); // 51.5
bool okForBody = BitThemeColorContrast.MeetsApcaBodyText(lc); // |Lc| >= 75
bool okForLarge = BitThemeColorContrast.MeetsApcaLargeText(lc); // |Lc| >= 60
bool okForUi = BitThemeColorContrast.MeetsApcaNonText(lc); // |Lc| >= 45
// The thresholds are public constants, for your own reporting / assertions.
double body = BitThemeColorContrast.ApcaBodyTextLc; // 75.0
double large = BitThemeColorContrast.ApcaLargeTextLc; // 60.0
double nonText = BitThemeColorContrast.ApcaNonTextLc; // 45.0Because the two methods measure differently, they can disagree — deliberately. A mid-tone accent that WCAG pushes toward dark on-text to clear 4.5:1 may actually read better with white text under APCA. Use APCA to sanity-check dark-theme readability; keep WCAG as the pass/fail gate you report against.
Translucent colors from tokens
Every
/* a 12% brand-tinted surface, in plain app CSS */
.highlight {
background-color: color-mix(in srgb, var(--bit-clr-pri) 12%, transparent);
}In C#,
var theme = new BitTheme();
// A brand-tinted modal overlay instead of the default black one.
theme.Color.Background.Overlay =
BitThemeUtilities.WithAlpha("var(--bit-clr-pri-dark)", 0.35);
await BitThemeManager.ApplyBitThemeAsync(theme);Theme utilities
// Flatten a theme to its CSS custom properties (~280 entries).
IReadOnlyDictionary<string, string> vars = BitThemeUtilities.ToCssVariables(myTheme);
// Merge two themes: 'overrides' wins, missing values fall through to 'baseline'.
BitTheme effective = BitThemeUtilities.Merge(overrides: brandOverrides, baseline: tenantTheme);// Persist a theme (camelCase JSON, sparse output)
string json = BitThemeSerialization.Serialize(myTheme, writeIndented: true);
// Load it back (every nested object is rehydrated, even if the JSON omitted it)
BitTheme restored = BitThemeSerialization.Deserialize(json);Design Tokens (DTCG) interop
// Export a theme as a DTCG token document for a designer / Style Dictionary
string dtcg = BitThemeDtcg.Export(myTheme);
// Import DTCG tokens (from Tokens Studio, a build pipeline, etc.) back into a theme
BitTheme fromDesign = BitThemeDtcg.Import(dtcg);
await BitThemeManager.ApplyBitThemeAsync(fromDesign);Exported tokens carry a DTCG
{
"color": {
"$type": "color",
"primary": {
"main": { "$value": "#1276C6" },
"mainHover": { "$value": "{color.primary.main}" }
}
}
}Density, layout, & RTL
var theme = new BitTheme
{
Layout =
{
DensityScale = "0.95", // → --bit-layout-density-scale
Breakpoints =
{
// → --bit-bp-{xs,sm,md,lg,xl,xxl}
Xs = BitThemeBreakpointDefaults.Xs,
Sm = BitThemeBreakpointDefaults.Sm,
Md = BitThemeBreakpointDefaults.Md,
Lg = BitThemeBreakpointDefaults.Lg,
Xl = BitThemeBreakpointDefaults.Xl,
Xxl = BitThemeBreakpointDefaults.Xxl,
}
},
Spacing = { ScalingFactor = "0.5rem" },
};RTL is not a theme token: set
// Compact overlay = Layout.DensityScale "0.9" (unitless multiplier over the spacing unit)
var compact = BitThemeDensityPresets.CreateCompactOverlay();
// Spacious overlay = Layout.DensityScale "1.1" - for touch-first / airy layouts
var spacious = BitThemeDensityPresets.CreateSpaciousOverlay();
// Merge on top of your tenant theme
var dense = BitThemeUtilities.Merge(overrides: compact, baseline: tenantTheme);
await BitThemeManager.ApplyBitThemeAsync(dense);Motion & accessibility
Motion tokens drive transitions and animations across components:
var theme = new BitTheme
{
Motion =
{
Duration = "200ms", // --bit-mot-duration
DurationShort = "100ms", // --bit-mot-duration-short
DurationLong = "300ms", // --bit-mot-duration-long
EasingStandard = "cubic-bezier(0.33, 0, 0.67, 1)", // --bit-mot-easing
// Looping animations (spinners, loaders, indeterminate bars) cannot use the durations
// above: those collapse to a near-zero value under reduced motion, which renders as
// flicker rather than as stillness. They have their own tokens, which slow down instead.
DurationSpinner = "1.3s", // --bit-mot-duration-spinner
EasingSpinner = "cubic-bezier(0.53, 0.21, 0.29, 0.67)", // --bit-mot-easing-spinner
LoopFactor = "1", // --bit-mot-loop-factor
}
};One caveat when theming durations.
:root {
--bit-mot-duration-full: 200ms;
--bit-mot-duration-short-full: 100ms;
--bit-mot-duration-long-full: 300ms;
}Accessibility safety nets are built into the Fluent stylesheets:
Reduced motion. Under
Looping animations are slowed, not stopped. A spinner or a loader is the only thing telling the user that work is still in progress, so it keeps running under reduced motion — and collapsing it to
Forced colors / high contrast. Under
Stronger contrast. Under
External CSS bundles
@inject BitExternalThemeLoader ThemeLoader
// Attach (or update) a link element with id "tenant-theme"
await ThemeLoader.AttachStylesheetAsync("tenant-theme", "/themes/acme.css");
// Detach when the user signs out / switches tenant
await ThemeLoader.DetachStylesheetAsync("tenant-theme");Only same-origin URLs are accepted — relative paths or
CSS in C# (BitCss)
<body class="@BitCss.Class.Color.Background.Primary.Main @BitCss.Class.Color.Foreground.Primary.Main">
...
</body>
<div style="border-color: var(@BitCss.Var.Color.Border.Secondary.Main);
background: var(@BitCss.Var.Color.Background.Tertiary.Main);
box-shadow: var(@BitCss.Var.Shadow.Md);
border-radius:var(@BitCss.Var.Shape.BorderRadius);">
Hello world!
</div>Coverage.
JavaScript API
The runtime
get / set / toggle / useSystem — preset and OS-following helpers.
// Read the active theme name
const current = BitBlazorUI.Theme.get();
// Set a preset (or any name matching your CSS)
BitBlazorUI.Theme.set('fluent-dark');
// Toggle between configured light / dark names
BitBlazorUI.Theme.toggleDarkLight();
// Re-follow the OS even after the user picked an explicit preset
BitBlazorUI.Theme.useSystem();applyTheme / clearAppliedTheme — inline CSS variable overrides.
// Set inline CSS variables on document.body (or a target element)
BitBlazorUI.Theme.applyTheme(
{
'--bit-clr-pri': '#7C3AED',
'--bit-clr-pri-text': '#FFFFFF',
'--bit-shp-brd-radius': '0.5rem'
},
document.body
);
// Remove only the variables we set above
BitBlazorUI.Theme.clearAppliedTheme(document.body);onChange / isSystemDark / getPersisted — observation helpers.
BitBlazorUI.Theme.onChange((newTheme, oldTheme) => {
const meta = document.querySelector('meta[name=theme-color]');
meta?.setAttribute('content', newTheme.includes('dark') ? '#0d1117' : '#ffffff');
});
const dark = BitBlazorUI.Theme.isSystemDark(); // boolean
const stored = BitBlazorUI.Theme.getPersisted(); // null when persistence is offdocument.addEventListener('bit-theme-change', (e) => {
console.log(`theme: ${e.detail.oldTheme} -> ${e.detail.newTheme}`);
});init — manual bootstrap (the script self-initializes from
BitBlazorUI.Theme.init({
system: true,
persist: true,
theme: 'fluent-dark',
default: 'fluent-light',
darkTheme: 'brand-dark',
lightTheme: 'brand-light',
onChange: (newTheme, oldTheme) => {
document.querySelector('meta[name=theme-color]')
?.setAttribute('content', newTheme === 'brand-dark' ? '#0d1117' : '#ffffff');
}
});External stylesheets. The same loader is exposed in JS:
BitBlazorUI.ExternalTheme.attach('tenant-theme', '/themes/acme.css');
BitBlazorUI.ExternalTheme.detach('tenant-theme');End-to-end recipe
A common production setup combines a few of the pieces above. Bootstrap declaratively for first paint, switch presets via
1. Host document — opt into system tracking, persistence, and reduce flash:
<html bit-theme-system bit-theme-persist bit-theme-default="fluent-light">
<head>
@((MarkupString)BitThemeSsr.InlineHeadScript)
<link rel="stylesheet" href="@Assets["_content/Bit.BlazorUI/styles/bit.blazorui.css"]" />
</head>
...
</html>2. Service registration:
builder.Services.AddBitBlazorUIServices();3. Theme switcher component:
@inject BitThemeManager ThemeManager
@inject BitThemeNotifications ThemeNotifications
@implements IDisposable
<BitToggleButton Text=@(_isDark ? "Light" : "Dark") OnClick="ToggleTheme" />
<BitButton OnClick="FollowSystem">Follow OS</BitButton>
@code {
private bool _isDark;
protected override async Task OnInitializedAsync()
{
ThemeNotifications.ThemeChanged += OnThemeChanged;
var current = await ThemeManager.GetCurrentThemeAsync();
_isDark = current?.Contains("dark") == true;
}
private Task ToggleTheme() => ThemeManager.ToggleDarkLightAsync().AsTask();
private Task FollowSystem() => ThemeManager.SetThemeAsync(BitThemePresets.System).AsTask();
private void OnThemeChanged(object? sender, BitThemeChangedEventArgs e) =>
InvokeAsync(() =>
{
_isDark = e.NewTheme?.Contains("dark") == true;
StateHasChanged();
});
public void Dispose() => ThemeNotifications.ThemeChanged -= OnThemeChanged;
}4. Tenant brand region — derive a palette from a single hex and scope it:
<BitThemeProvider Theme="_tenantTheme">
<TenantDashboard />
</BitThemeProvider>
@code {
private BitTheme _tenantTheme = BuildTenantTheme(BitAccentColorPresets.Purple);
private static BitTheme BuildTenantTheme(string accent)
{
var theme = BitThemeFactory.CreateLightTheme(accent);
theme.Shape.BorderRadius = "0.5rem";
return theme;
}
}Migrating from the previous theming API
The theme system was rebuilt in this release. Most token names and the overall
1.
2. Manager methods return
3.
4. Typography variants were restructured. Per-variant
5. Theme names are normalized.
6. The packaged palettes were re-solved (visual, not source-breaking). Token names are unchanged, so nothing to update in code — but the values moved, and the differences are visible. The on-color now clears 4.5:1 on every fill it is painted on (it previously dipped to 2.3:1 on some dark-tier fills), disabled text clears 3:1, and the tertiary foreground is readable on all three surfaces. The dark canvas moved off near-black, severe-warning moved onto Fluent 2's own severe-warning hue, and the dark modal scrim is now a dark veil rather than a white one. If you pinned brand tokens on top of a packaged palette, re-check the pairs you pinned; if you hardcoded a packaged hex anywhere, take the value from the Design tokens section above.
7. Dark-scheme derivation now lightens on interaction.
Everything else is additive:
API quick reference
Bootstrap
Runtime
Authoring
Hosting
Authoring helpers in C#
JavaScript
Feedback
You can give us your feedback through our GitHub repo by filing a new Issue or starting a new Discussion.