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

<html>
— declarative bootstrap. Pick a preset, follow OS appearance, persist the user's choice, or rename the dark/light keys. No code required.

2. C# APIs
BitThemeManager
for runtime preset switching and token overrides,
BitThemeProvider
for scoped overrides on a subtree,
BitThemeNotifications
for change events.

3. JavaScript API
BitBlazorUI.Theme
mirrors the C# manager for non-Blazor code (host pages, custom scripts, third-party widgets).


Underneath, every component reads

--bit-*
CSS custom properties. Presets set them via stylesheet rules selected by the
bit-theme
attribute — on
:root
for the whole document, or on any element to re-theme just that subtree (scoped presets, see the Presets section);
BitThemeProvider
and
ApplyBitThemeAsync
override them via inline
style
. Inline wins, so brand tweaks apply on top of any preset.


Quick setup


Register the bit BlazorUI services so

BitThemeManager
,
BitThemeNotifications
, and
BitExternalThemeLoader
are available via DI:


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:

bit-theme-system
resolves to
light
or
dark
from
prefers-color-scheme
, and
bit-theme-persist
stores the user's eventual explicit choice in
localStorage
(key
bit-current-theme
) so the next visit restores it.


HTML attributes


Every theme behavior can be configured declaratively on the

<html>
element. Names are normalized through
BitThemeAttributeNames
so the SSR script and the runtime script stay in sync.


<!-- 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>

bit-theme-view-transition
is purely presentational: when present, every theme swap (from
SetThemeAsync
,
ToggleDarkLightAsync
, the JS API, or an OS appearance change while following the system) runs inside
document.startViewTransition
, so the page cross-fades to the new palette instead of hard-swapping. It is checked live on every change — add or remove it at runtime to turn the effect on or off — and it is skipped automatically (falling back to an instant swap) in browsers without the View Transitions API and for users with
prefers-reduced-motion: reduce
. Style the animation itself in CSS via the standard
::view-transition-old(root)
/
::view-transition-new(root)
pseudo-elements (e.g. a circular reveal instead of the default cross-fade).


Resolution order (mirrored in the SSR script and

BitBlazorUI.Theme.init
):


1.

bit-theme
/
bit-theme-default
 →  the base preset (
bit-theme
wins over
bit-theme-default
when both are present).
2.
bit-theme-system
(or a base value of
system
)  →  overrides the base preset with the OS choice, resolved from
prefers-color-scheme
using
bit-theme-light
/
bit-theme-dark
(default
light
/
dark
).
3.
bit-theme-persist
/
bit-theme-persist-cookie
 →  a stored preference overrides both. A concrete stored preset also stops the OS-following; a stored
system
keeps following and resolves like step 2.


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

--bit-clr-*
. Eight semantic roles (
pri
,
sec
,
ter
,
inf
,
suc
,
wrn
,
swr
,
err
), each with the same slots: a nine-tone ramp (
-dark*
/ base+
-hover
/
-active
/
-light*
) plus
-text
(the on-color),
-dis
/
-dis-text
, and
-focus

Background, foreground, border
--bit-clr-bg-*
,
--bit-clr-fg-*
,
--bit-clr-brd-*
(the same nine-tone shape, in primary/secondary/tertiary tiers), plus
--bit-clr-bg-overlay
(modal scrim) and
--bit-clr-req
(required-field marker)
Shadow
--bit-shd-*
(callouts, sizes
sm
2xl
,
focus-ring
, plus
1
24
elevation steps)
Shape
--bit-shp-brd-radius
,
--bit-shp-brd-width
,
--bit-shp-brd-style
,
--bit-shp-focus-ring-width
,
--bit-shp-focus-ring-offset

Spacing
--bit-spa-scaling-factor

Z-index
--bit-zin-{snackbar,callout,overlay,modal,base}
(stacking order, highest first —
overlay
is the click-away layer of callouts and intentionally sits above
modal
)
Typography
--bit-tpg-*
(font family/weight/line-height plus per-variant
h1..h6
,
body1/2
,
subtitle1/2
,
caption1/2
,
button
,
overline
,
inherit
)
Motion
--bit-mot-duration
,
--bit-mot-duration-short
,
--bit-mot-duration-long
,
--bit-mot-easing
, the looping set
--bit-mot-duration-spinner
,
--bit-mot-easing-spinner
,
--bit-mot-loop-factor
, plus the unreduced
*-full
sources of both (see Motion & accessibility)
Layout
--bit-layout-density-scale
, breakpoints
--bit-bp-{xs,sm,md,lg,xl,xxl}

Semantic aliases
--bit-sem-*
(intent-named pointers to the primitives)
Per-component
--bit-<cmp>-*
(each component's own knobs; see Per-component variables below)


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 (

-dark
, the base, and
-light
), each carrying the same two interaction steps (
-hover
,
-active
). Knowing the shape means you can predict any token name, and overriding a tier keeps its states in proportion.


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):


-text
holds ≥ 4.5:1 over every fill it is painted on — that is the base tier and the
-dark
tier, which
BitToggleButton
's checked state and
BitPagination
's selected page fill with while still drawing the on-color as text.
• The
-light
tier is a standalone tint; the on-color is never painted over it, so it is free to be as pale as it likes.
-dis-text
holds ≥ 3:1 over
-dis
. Disabled controls are exempt from WCAG 1.4.3, but text nobody can read is still text nobody can read.
--bit-clr-fg-pri
and
--bit-clr-fg-sec
clear 4.5:1 over
bg-pri
,
bg-sec
and
bg-ter
(they sit at 14.6:1 and 6.6:1 even on the deepest of the three).
--bit-clr-fg-ter
, the subtle/placeholder tier, clears 4.5:1 on the page surface and stays above 4:1 on the deepest one.
--bit-clr-brd-pri
keeps ≥ 3:1 over all three (SC 1.4.11, non-text contrast).
brd-sec
and
brd-ter
are the decorative stroke tiers — spinner tracks, menu separators, calendar grid lines — and are tuned for definition rather than to a contrast floor. Reach for
brd-pri
when a border is a control boundary.


Two roles carry a documented exception:

sec
and
wrn
are a vivid orange and a warning amber, and no tone of those hues clears 4.5:1 as text on a white page while still reading as itself — the same tension Fluent resolves by shipping separate fill and foreground tokens. They are fully compliant as fills; when you need them as text or as a meaningful icon on a light surface, use their
-dark
tones, which clear the 3:1 non-text floor.


In the dark palettes the page canvas deliberately sits a little off pure black (

--bit-clr-bg-pri
is a very dark blue-grey, not
#000
): near-black canvases make bright text halate, and they leave no room for
bg-sec
/
bg-ter
to read as elevation steps.


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

var()
references, so they track whatever palette is loaded). They live in semantic-tokens.scss:


--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()
references at the element that defines them, so for inline overrides (
BitThemeProvider
/
ApplyBitThemeAsync
) the theme system automatically re-declares any alias whose target primitive the theme touches on the same element — keeping the alias in lock-step with the override for that subtree, while untouched intents (and explicitly-set alias values) are left alone. Semantic tokens are themable from C# via
BitTheme.Color.Semantic
:


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

scss
, the _bit-css-variables.scss file mirrors every
--bit-*
token as an SCSS variable so you can write
color: $clr-pri
instead of
var(--bit-clr-pri)
.


Per-component variables


Below the theme tokens sits a third tier: every component exposes its own themable knobs as

--bit-<cmp>-*
custom properties (for example
--bit-btn-clr
,
--bit-btn-padding
,
--bit-chb-box-size
), assigned on the component root by its role/variant classes and consumed by its internal selectors. These names are stable, contract-tested public API — the full inventory (currently 268 variables across 31 components) is published in component-css-variables.md, and a build-time test fails if any name drifts without an inventory update.


Override them app-wide from your CSS, or per instance via the component's

Style
/
Class
parameters — a surgical alternative to a full
BitTheme
when you only need to retune one component:


/* 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


BitThemePresets
exposes the built-in theme names. Use them with
SetThemeAsync
,
BitThemeName
, or directly as the
bit-theme
attribute value.


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

BitThemeName
readonly struct wraps a normalized (trimmed, lower-cased) preset name and converts implicitly to
string
:


// 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

bit-theme
attribute is not limited to
<html>
: put it on any element and the named packaged palette (colors, shadows,
color-scheme
, and the semantic aliases) re-themes just that subtree via CSS custom-property inheritance — no C# involved. Use it for an always-dark hero section, a print-preview that stays light, or side-by-side theme comparisons:


<!-- 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

BitThemeProvider
are complementary: the attribute swaps a subtree to a named palette from CSS, the provider overlays individual token values from a C#
BitTheme
object — and they can nest. One caveat applies to both: components that render at the document level (modals, callouts with
AbsolutePosition
disabled, snackbars) escape the subtree in the DOM, so they inherit the document palette rather than the scoped one.


Pair a preset with

BitAccentColorPresets
when you want a starter brand hex without picking your own:


// 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;    // #C239B3

Feeding

Blue
to
BitThemeFactory
reproduces the packaged primary, which makes it a useful baseline when you want to see how much a candidate brand color diverges from the default.


BitThemeManager


BitThemeManager
is the C# counterpart of the JS theme runtime. Inject it from any component or service:


[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

ApplyBitThemeAsync
writes a
BitTheme
as inline CSS variables on the target element (default:
document.body
). Inline beats stylesheet rules for the same property, so this is how you brand-tweak on top of a preset:

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

SetThemeAsync
swaps the preset by changing the
bit-theme
attribute on the document root, so the packaged Fluent stylesheet supplies base values.
ApplyBitThemeAsync
overlays inline CSS variables on top.
BitThemeProvider
overlays inline CSS variables on a subtree. Inline always wins when the same property is set, so combine them freely:


// 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 BitThemeProvider

BitThemeProvider


BitThemeProvider
applies a
BitTheme
to a subtree without touching
document.body
. Use it for component-level theming, multi-tenant regions, or previewing brand changes side by side.


<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
:


[CascadingParameter] public BitTheme? Theme { get; set; }

Providers nest, and inner overrides win. Missing values cascade through the parent (same rules as

BitThemeUtilities.Merge
):


<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

RootElement
when you need a different host tag (default is
div
) and add HTML attributes via splatting. The provider only renders a wrapping element when it actually contributes overrides; otherwise it forwards
ChildContent
as-is.


<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

ThemeName
and read it with
[CascadingParameter(Name = "…")]
.


Performance: the

Frozen
parameter. By default the provider rebuilds its merged theme and CSS-variable string on every parameters update, so mutating a
BitTheme
instance in place is picked up automatically. When you hand the provider a stable instance and never mutate it, set
Frozen="true"
to skip that per-render rebuild entirely while the
Theme
/ parent-theme references are unchanged. The trade-off: in-place mutations of a frozen theme are no longer detected — assign a new
BitTheme
instance to apply changes.


<BitThemeProvider Theme="_stableTheme" Frozen="true">
    <HeavySubtree />
</BitThemeProvider>

Theme change notifications (.NET)


BitThemeNotifications
is a scoped DI service that fires
ThemeChanged
whenever
bit-theme
changes — whether the change came from
SetThemeAsync
,
ToggleDarkLightAsync
, the JS API, or the OS while following
prefers-color-scheme
.


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

BitThemeNotifications
is scoped, so a leaked handler keeps a disposed component rooted for the lifetime of the circuit.
NewTheme
and
OldTheme
are nullable; check
HasNewTheme
/
HasOldTheme
instead of comparing against the empty string.


Reduce theme flash (BitThemeSsr)


Server-rendered apps that use

bit-theme-persist
and/or
bit-theme-system
can flash the wrong theme on first paint while Blazor hydrates.
BitThemeSsr
emits a tiny synchronous script that resolves the correct
bit-theme
from
localStorage
/
prefers-color-scheme
before stylesheets run.


Drop the full

<script>
at the very start of
<head>
in your root document (e.g.
Components/App.razor
):


<head>
    @((MarkupString)BitThemeSsr.InlineHeadScript)

    <!-- bit BlazorUI / Fluent stylesheet links AFTER the script -->
    <link rel="stylesheet" href="..." />
</head>

Need a CSP

nonce
? Use
BuildInlineHeadScript(nonce)
:


@((MarkupString)BitThemeSsr.BuildInlineHeadScript(Context.Items["csp-nonce"]?.ToString()))

Or wrap the body yourself when you need full control of script attributes (

defer
,
type
, …):


<script>@BitThemeSsr.InlineHeadScriptBody</script>

The script logic mirrors the runtime

BitBlazorUI.Theme
: with
bit-theme-persist
, the stored key
bit-current-theme
is read; with
bit-theme-persist-cookie
, the
bit-theme-preference
cookie is used as a fallback when
localStorage
holds no value; if the resolved value is
system
,
prefers-color-scheme
picks the light or dark name from
bit-theme-light
/
bit-theme-dark
(defaulting to
light
and
dark
).


SSR cookie convention


BitThemeSsr
handles the client side. For full server-side parity, add
bit-theme-persist-cookie
so the client mirrors the user's preference into a cookie, then read that cookie on the server and emit the matching
bit-theme
attribute.
BitThemeCookie.PreferenceCookieName
documents the conventional cookie name (
bit-theme-preference
) so client and server agree:


// 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

[a-z0-9-]
theme-name pattern; anything else is ignored and treated as missing, so a tampered cookie cannot inject markup into your document.


With

bit-theme-persist-cookie
on
<html>
, the runtime script mirrors every theme change into the
bit-theme-preference
cookie automatically, keeping the client (
localStorage
) and server (cookie) stores in sync — no manual
BitThemeNotifications.ThemeChanged
subscription is needed for that.


Color derivation & contrast


The fastest path from a brand color to a working theme is

BitThemeFactory
: give it one hex value and it derives the complete primary role - all 13 slots - for a light and a dark scheme. The same brand color feeds both; the dark factory brightens it toward white at constant hue so the accent keeps its identity on a dark surface, mirroring the relationship between the packaged fluent and fluent-dark primaries (
#1276C6
#4FA3F4
, the same hue at a higher lightness).


// 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


CreateLightTheme
recolors the accent and leaves everything else to the packaged stylesheet.
CreateLightThemeFromSeed
goes the rest of the way: one hex in, and you get the second accent, the status roles, every surface / text / stroke tier, and the gray ramp — a complete palette rather than an overlay.


// 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

NeutralTintChroma
adds a tint. The status roles keep their conventional green/amber/red. Rotating them onto the seed is never offered — a blue "error" is a bug report waiting to happen — but
SemanticHarmonizationDegrees
will lean them a few degrees toward the brand if you want that.


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,

brd-pri
≥ 3:1 — and tests grid them over ~200 seeds in both schemes.


Seeding

BitAccentColorPresets.Blue
— the packaged palettes' own primary — reproduces them exactly: all 199 tokens, byte for byte, in both schemes, which a test pins. Two tiers are deliberately left unset so they keep tracking rather than freezing: the
--bit-sem-*
aliases and the
--bit-clr-*-focus
indicators are
var()
references onto the primitives this fills, so they follow the generated palette on their own.


Try it. Each swatch below feeds one

BitAccentColorPresets
hex through
CreateLightThemeFromSeed
/
CreateDarkThemeFromSeed
and applies the result to this page — watch the surfaces, borders and body text move with the accent, not just the buttons. Toggle dark/light in the header afterwards to watch the same brand color get re-derived for the other scheme, and note that the choice survives a refresh (it is the same switcher as the one on the home page).



Under the hood,

BitThemeColorDerivation
fills the role steps (
Main
,
MainHover
,
MainActive
,
Dark
,
Light
,
Disabled
,
Focus
,
Text
, …) from a single main color in the perceptually uniform OKLCH space, at constant hue, with step sizes calibrated to the packaged Fluent palettes — so a derived role behaves like a packaged one, including the interaction direction: in the light scheme the states step down in lightness, in the dark scheme they step up (toward the light source), matching the packaged dark palette. Derived interactive fills keep ≥3:1 against the auto-selected
Text
for any seed. Caller-set values are preserved, so you can pin any slot and derive the rest.


The

Disabled
/
DisabledText
pair is the one family that is not a relative step off main: it targets an absolute lightness with the chroma capped to a faint trace of the hue. That is deliberate — a relative mix would inherit main's lightness, so a bright role and a dark one would land on different disabled weights and the state would read as a per-role variant instead of one recognizable state. Those targets are the packaged palettes' own, so deriving from a packaged role reproduces its disabled pair exactly (a test pins it).


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

BitThemeColorContrast
:


var ratio = BitThemeColorContrast.GetContrastRatio("#FFFFFF", "#1276C6"); // 4.74

bool passesNormal = BitThemeColorContrast.MeetsWcagAaNormalText(ratio); // true
bool passesLarge  = BitThemeColorContrast.MeetsWcagAaLargeText(ratio);  // true

APCA 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

BitThemeColorDerivation
are tuned to WCAG.


// 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.0

Because 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

--bit-*
color token holds a complete color, so alpha is composited at the point of use with
color-mix()
- no per-alpha token variants or separate channel variables are needed, and it works even when the concrete color is only known in the browser (a
var()
chain,
currentcolor
, a forced-colors system color):


/* a 12% brand-tinted surface, in plain app CSS */
.highlight {
    background-color: color-mix(in srgb, var(--bit-clr-pri) 12%, transparent);
}

In C#,

BitThemeUtilities.WithAlpha
builds the same expression - validated against the theme system's injection screening, so the result is safe to assign to any
BitTheme
token:


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


BitThemeUtilities
exposes the same primitives the manager and provider use internally.


// 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);

BitThemeSerialization
serializes a theme as JSON for storage or admin UIs. Empty branches are pruned, and deserialization restores every nested object so downstream helpers never see
null
:


// 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


BitThemeDtcg
round-trips a theme through the W3C Design Tokens Community Group format, so a
BitTheme
can flow to and from design tooling that speaks DTCG — Tokens Studio (Figma), Style Dictionary, and the wider token pipeline. It uses bit's own group structure as the token namespace (
color.primary.main
,
boxShadow.md
, …) and the string-value token profile the ecosystem consumes:


// 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

$type
on the uniformly-typed groups (
color
,
boxShadow
shadow
,
spacing
dimension
,
zIndex
number
). On import, DTCG aliases (
"{color.primary.main}"
) are resolved to concrete values, and the 2025.10 object forms for
color
/
dimension
plus numeric values are accepted:


{
  "color": {
    "$type": "color",
    "primary": {
      "main":      { "$value": "#1276C6" },
      "mainHover": { "$value": "{color.primary.main}" }
    }
  }
}

Density, layout, & RTL


BitTheme.Layout
drives the layout-level tokens. Set
DensityScale
for compact spacing and
Breakpoints
to align with your app media queries.


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

dir="rtl"
on
<html>
(or your root element) and use the components'
Dir
parameter, so HTML-level features like text selection and cursor direction follow.


BitThemeDensityPresets.CreateCompactOverlay()
ships a ready-made overlay you can merge with your baseline theme:


// 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);

BitThemeBreakpointDefaults
exposes the recommended widths in pixels (
Xs = 0px
,
Sm = 600px
,
Md = 960px
,
Lg = 1280px
,
Xl = 1920px
,
Xxl = 2560px
) so your media queries and theme breakpoints stay aligned.


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.

BitTheme.Motion.*
writes the effective tokens (
--bit-mot-duration
…), and those are exactly the tokens the reduced-motion media query collapses to
0.01ms
— but it does so on
:root
, while
ApplyBitThemeAsync
/
BitThemeProvider
write inline on the body or a subtree, and inline wins. A duration set through the C# theme therefore also applies for users who asked for reduced motion. To theme durations and keep the opt-out working, set the
--bit-mot-duration*-full
sources in CSS instead — they feed the effective tokens in both states:


: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

prefers-reduced-motion: reduce
, all
--bit-mot-duration*
tokens collapse to
0.01ms
, so transitions effectively disable. The unreduced values stay available in the matching
--bit-mot-duration*-full
tokens, which is also where you change a duration since it feeds both states. A single component can opt out with the
ForceAnimation
parameter (it renders the
bit-fam
class, which restores the full durations for that element and its content).

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
0.01ms
would render as flicker rather than as stillness. Instead
--bit-mot-duration-spinner
goes to
4s
and
--bit-mot-easing-spinner
to
linear
, while
--bit-mot-loop-factor
rises to
3
, stretching every other looping animation (the indeterminate
BitProgress
bar, the staggered
BitLoading
variants) as a whole so the phase offsets between their parts survive. All three have
-full
sources and are restored by
ForceAnimation
, exactly like the durations above. The exception is a shimmer or skeleton — a placeholder for content that has not arrived rather than a progress signal — which stops outright.

Forced colors / high contrast. Under
forced-colors: active
(Windows High Contrast), the palette switches to system colors (
CanvasText
,
Canvas
,
Highlight
,
LinkText
), shadows drop to
none
, and the focus ring uses the system
Highlight
color so it stays visible.

Stronger contrast. Under
prefers-contrast: more
the library does three things, all theme-agnostic (no hardcoded colors, so it applies to light, dark, and your own palettes alike): borders thicken (
--bit-shp-brd-width
1px → 2px); the focus indicator strengthens (
--bit-shp-focus-ring-width
2px → 3px with a matching offset bump, flowing through
--bit-shd-focus-ring
to every focused control); and the two subtlest tiers are promoted a step —
--bit-clr-brd-ter
and
--bit-clr-fg-ter
are re-pointed at their
-sec
counterparts, which lifts the weakest pairs without touching the palette itself. Disabled and muted colors are deliberately not promoted: their meaning depends on staying subdued.


External CSS bundles


BitExternalThemeLoader
swaps an external stylesheet at runtime by id (creates or updates a
<link rel="stylesheet">
). Useful for tenant-specific palettes, brand variants, or seasonal themes shipped as separate CSS files.


@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

http(s)
URLs on the app's own origin; cross-origin URLs (e.g. CDNs) are rejected. Unsafe schemes such as
javascript:
,
data:
, and
vbscript:
are rejected as well. Always pass URLs you control or have explicitly trusted.


CSS in C# (BitCss)


BitCss
exposes the same tokens as strongly-typed constants.
BitCss.Class
gives you
bit-css-*
utility class names, and
BitCss.Var
gives you the matching
--bit-*
CSS custom property names. Refactor-friendly, IntelliSense-driven, and free of magic strings.


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

BitCss.Var
wraps every theme token family: color (including the per-role
Focus
/
Disabled
/
DisabledText
slots), shadow (with
Shadow.FocusRing
), z-index, shape (with the focus-ring width/offset), spacing, typography, motion (
BitCss.Var.Motion
), layout & breakpoints (
BitCss.Var.Layout
), and the semantic aliases (
BitCss.Var.Semantic
).


JavaScript API


The runtime

BitBlazorUI.Theme
object mirrors
BitThemeManager
. Use it from host pages, custom scripts, or component libraries that aren't Blazor.


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 off

onChange
holds a single callback (a later call, or an
init
with an
onChange
option, replaces the previous one). When several independent consumers need to observe theme changes, listen to the
bit-theme-change
CustomEvent
instead — it is dispatched on
document
after every change (including OS-driven ones), supports any number of listeners, and works from third-party scripts that know nothing about bit:

document.addEventListener('bit-theme-change', (e) => {
    console.log(`theme: ${e.detail.oldTheme} -> ${e.detail.newTheme}`);
});

init — manual bootstrap (the script self-initializes from

<html>
attributes; call
init
only when you bypass them).

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

BitThemeManager
, override brand tokens via
BitThemeProvider
, and react to changes through
BitThemeNotifications
.


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

BitTheme
→ CSS-variable flow are unchanged, but a few members moved or changed shape. This is the complete list of source-breaking changes:


1.

IsSystemInDarkMode()
is now
IsSystemInDarkModeAsync()
.
A pure rename to follow the async naming convention — update call sites.


2. Manager methods return

ValueTask<string?>
instead of
ValueTask<string>
.
GetCurrentThemeAsync
,
SetThemeAsync
,
ToggleDarkLightAsync
, and
GetCurrentPersistedThemeAsync
return
null
when JS interop is unavailable (prerendering or a disconnected circuit) — the previous signatures already produced
null
at runtime in those states but claimed non-null, so the annotation now tells the truth. With nullable reference types enabled you may get new warnings at call sites; treat
null
as "not resolvable yet".


3.

BitThemeManager
is now
IAsyncDisposable
.
DI-resolved instances are disposed by the container automatically; if you construct one manually, dispose it. Calling any method after disposal throws
ObjectDisposedException
.


4. Typography variants were restructured. Per-variant

FontFamily
was removed — the font family is global (
Typography.FontFamily
), with
Typography.Inherit.FontFamily
as the per-element escape hatch.
TextTransform
and
Display
now exist only on the
Button
,
Overline
, and
Inherit
variants, which are typed
BitThemeLabelTypographyVariants
(or a derivative) — so
Typography.Button = new BitThemeTypographyVariants()
no longer compiles; assign a
BitThemeLabelTypographyVariants
or set properties on the existing instance instead.


5. Theme names are normalized.

SetThemeAsync("Dark")
now writes the canonical lowercase
dark
onto the
bit-theme
attribute (matching the packaged
:root[bit-theme=…]
selectors and
BitThemeName
). Compare returned theme names case-insensitively or against the canonical lowercase form.


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.

BitThemeColorDerivation.FillColorRoleFromMain(…, BitThemeColorScheme.Dark)
(and therefore
BitThemeFactory.CreateDarkTheme
) used to darken hover/active on a dark surface; it now steps them up in lightness, matching the packaged dark palette and Fluent 2's dark hover tokens. Themes you derive from a brand color will hover the opposite way from before — which is the point: previously a derived dark role and a packaged one reacted in opposite directions in the same UI. Slots you set explicitly are still never overwritten.


Everything else is additive:

BitThemeName
(typed theme names),
BitThemeNotifications
(theme-change events; subscribing alone wires them up),
BitThemeSsr
and the
bit-theme-preference
cookie (flash-free first paint),
BitThemeColorDerivation
/
BitThemeColorContrast
(palette generation and WCAG + advisory APCA checks), the density/accent presets, and
BitExternalThemeLoader
— each covered in its own section above.


API quick reference


Bootstrap

AddBitBlazorUIServices()
 · 
BitThemeAttributeNames
 · 
BitThemePresets
 · 
BitThemeName


Runtime

BitThemeManager
:
SetThemeAsync
,
ToggleDarkLightAsync
,
GetCurrentThemeAsync
,
GetCurrentPersistedThemeAsync
,
IsSystemInDarkModeAsync
,
ApplyBitThemeAsync
,
ClearBitThemeOverridesAsync
,
EnsureThemeNotificationsRegisteredAsync


BitThemeProvider
(component):
Theme
,
RootElement
,
ThemeName
,
Frozen
, splatted attributes

BitThemeNotifications.ThemeChanged
 · 
BitThemeChangedEventArgs


Authoring

BitTheme
 · 
BitThemeFactory
(CreateLightTheme, CreateDarkTheme, CreateLightThemeFromSeed, CreateDarkThemeFromSeed)  · 
BitThemeSeedOptions
 · 
BitThemeUtilities
(Merge, ToCssVariables, WithAlpha)  · 
BitThemeSerialization
(Serialize, Deserialize)  · 
BitThemeDtcg
(Export, Import)  · 
BitThemeColorDerivation
(FillColorRoleFromMain)  · 
BitThemeAccentColors
 · 
BitThemeColorScheme
 · 
BitAccentColorPresets
 · 
BitThemeBreakpointDefaults
 · 
BitThemeDensityPresets
(CreateCompactOverlay, CreateSpaciousOverlay)

BitThemeColorContrast
:
GetContrastRatio
,
MeetsWcagAaNormalText
,
MeetsWcagAaLargeText
,
GetApcaContrast
,
MeetsApcaBodyText
,
MeetsApcaLargeText
,
MeetsApcaNonText
,
ApcaBodyTextLc
/
ApcaLargeTextLc
/
ApcaNonTextLc


Hosting

BitThemeSsr
:
InlineHeadScript
,
InlineHeadScriptBody
,
BuildInlineHeadScript(nonce)
,
BuildRootThemeAttributes(preference, defaultTheme)

BitThemeCookie.PreferenceCookieName
 · 
bit-theme-persist-cookie
(attribute)  · 
bit-theme-view-transition
(attribute)
BitExternalThemeLoader
:
AttachStylesheetAsync
,
DetachStylesheetAsync


Authoring helpers in C#

BitCss.Class
bit-css-*
utility class names
BitCss.Var
--bit-*
custom property names


JavaScript

BitBlazorUI.Theme
:
get
,
set
,
toggleDarkLight
,
useSystem
,
applyTheme
,
clearAppliedTheme
,
onChange
,
isSystemDark
,
getPersisted
,
init

BitBlazorUI.ExternalTheme
:
attach
,
detach


Feedback


You can give us your feedback through our GitHub repo by filing a new Issue or starting a new Discussion.


Or you can review / edit this page on GitHub.