Documentation
Theming
Every component reads its color, shape, size, typography and motion from --bit-* CSS
custom properties, so a theme is data rather than a stylesheet fork. The default is Microsoft's
Fluent design system in light and dark; Fluent 2, Material and Cupertino ship with the
Bit.BlazorUI.Extras package as one extra stylesheet link each, and every token in the
set is overridable.
At a glance
The three layers a theme is made of, and which one to reach for.
The theme system has three cooperating layers. Pick the layer that matches the change you want to make:
- 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. - C# APIs —
BitThemeManagerfor runtime preset switching and token overrides,BitThemeProviderfor scoped overrides on a subtree,BitThemeNotificationsfor change events. - JavaScript API —
BitBlazorUI.Thememirrors 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 Scoped presets below); BitThemeProvider and ApplyBitThemeAsync override them via inline style. Inline wins, so brand tweaks apply on top of any preset.
Quick setup
Two steps to a theme that follows the operating system and remembers what the visitor picked.
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
Everything the theme runtime does is configurable on the html element, before a line of code runs.
Every attribute
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>Animated theme swaps
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 in BitBlazorUI.Theme.init:
bit-theme/bit-theme-default→ the base preset (bit-themewins overbit-theme-defaultwhen both are present).bit-theme-system(or a base value ofsystem) → overrides the base preset with the OS choice, resolved fromprefers-color-schemeusingbit-theme-light/bit-theme-dark(defaultlight/dark).bit-theme-persist/bit-theme-persist-cookie→ a stored preference overrides both. A concrete stored preset also stops the OS-following; a storedsystemkeeps following and resolves like step 2.
Design tokens
The --bit-* custom properties every component reads, and the tiers that sit between them and a component.
Token families
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. This is the map; the three sections after it list what each family holds.
- Color —
--bit-clr-*, the eight semantic roles and the tones of each - Background, foreground, border —
--bit-clr-bg-*,--bit-clr-fg-*,--bit-clr-brd-*, the neutral surface tiers - Shadow —
--bit-shd-*, elevation by size and by surface family - Shape —
--bit-shp-*, corners, strokes and the focus ring - Size —
--bit-siz-*, the geometry of controls, glyphs and components - Spacing —
--bit-spa-*, the spacing unit and the dialog inset - Z-index —
--bit-zin-*, stacking order - Typography —
--bit-tpg-*, the type ramp, the weights and theBitTextvariants - Motion —
--bit-mot-*, durations and easing curves - Opacity —
--bit-opa-*, the disabled alpha - Layout —
--bit-layout-*and--bit-bp-*, density, dialog footers and breakpoints - Semantic aliases —
--bit-sem-*, intent-named pointers to the primitives (see Semantic aliases below) - 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.
Color and elevation
Color — --bit-clr-*. Eight semantic roles (pri, sec, ter, inf, suc, wrn, swr, err), each with the same slots:
-dark*/ base +-hover/-active/-light*— the nine-tone ramp-text— the on-color-dis/-dis-text— the disabled pair-focus— the focus indicator
Background, foreground, border — the neutral surfaces:
--bit-clr-bg-*,--bit-clr-fg-*,--bit-clr-brd-*— the same nine-tone shape, in primary/secondary/tertiary tiers--bit-clr-bg-overlay— the modal scrim--bit-clr-req— the required-field marker
Shadow — --bit-shd-*:
--bit-shd-sm…--bit-shd-2xl— the callout shadows, by size--bit-shd-focus-ring— the focus ring--bit-shd-1…--bit-shd-24— the elevation steps--bit-shd-{card,popup,dialog,sheet,tooltip,snackbar,appbar-top,appbar-bottom}— the per-surface elevations that components actually consume (see Family tokens below)
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 */
}Shape, size and layout
Shape — --bit-shp-*:
--bit-shp-brd-radius,--bit-shp-brd-width,--bit-shp-brd-width-thick,--bit-shp-brd-style— the base corner and stroke--bit-shp-focus-ring-width,--bit-shp-focus-ring-offset— the focus ring--bit-shp-radius-{none,xs,sm,md,lg,xl,2xl,full}— the radius scale--bit-shp-radius-{control,surface,popup,dialog}— the per-family radii--bit-shp-radius-{button,chip,selection}— the three control sub-families (buttons, tags/chips, the checkbox box), each falling back tocontrol
Size — --bit-siz-*:
--bit-siz-ctrl-{sm,md,lg}— control heights: 26/32/40px--bit-siz-ctrl-pad-x-{sm,md,lg}/--bit-siz-ctrl-pad-y-{sm,md,lg}— control padding: 12/16/20px horizontal, 4/6/8px vertical--bit-siz-ctrl-min-width—autounder Fluent; Material sets 64px--bit-siz-icon-{sm,md,lg}— glyphs inside controls: 12/16/20px--bit-siz-sel-{sm,md,lg}— checkbox box / radio ring: 14/20/26px--bit-siz-item-{sm,md,lg}— popup list row heights: 30/36/44px--bit-siz-tab/--bit-siz-tab-indicator— pivot header height and selection-indicator stroke: 44px / 2px--bit-siz-divider— separator thickness; follows--bit-shp-brd-widthuntil a preset thins it — Cupertino's 0.5px hairline--bit-siz-track-{sm,md,lg}— linear progress track thickness: 2/4/8px--bit-siz-switch-{w,h,thumb}-{sm,md,lg}— BitToggle's track and knob: 40x20 with a 12px knob at md; Material 52x32/24, Cupertino 51x31/27--bit-siz-slider-thumb-{sm,md,lg}— BitSlider's handle: 12/16/24px--bit-siz-spinner-stroke— the stroke of an inline spinner--bit-siz-popup-max-height— the height a scrolling popup list stops at
Spacing — --bit-spa-*:
--bit-spa-scaling-factor— the spacing unit everything else is a multiple of--bit-spa-dialog— the inset padding of dialogs and message boxes: 24px
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.
Layout:
--bit-layout-density-scale— the density multiplier--bit-layout-dialog-actions-direction/--bit-layout-dialog-actions-justify/--bit-layout-dialog-actions-align— how dialog and message-box footers lay out their action buttons:row/flex-end/centerunder Fluent; Cupertino stacks them full width withcolumn/center/stretch--bit-bp-{xs,sm,md,lg,xl,xxl}— the breakpoints
Type, motion and state
Typography — --bit-tpg-*:
- the base font family, weight and line-height
--bit-tpg-fs-{2xs,xs,sm,md,lg,xl,2xl,3xl,4xl}— the font-size scale, 10…32px; component size classes readsm→xs,md→sm,lg→md--bit-tpg-fw-{light,regular,medium,semibold,bold}— the weight scale--bit-tpg-ctrl-letter-spacing/--bit-tpg-ctrl-text-transform— the control-label pair- the
BitTextvariantsh1..h6,body1/2,subtitle1/2,caption1/2,button,overline,inherit
Motion — --bit-mot-*:
--bit-mot-duration,--bit-mot-duration-short,--bit-mot-duration-long,--bit-mot-easing— the state-transition set--bit-mot-easing-decelerate/--bit-mot-easing-accelerate— the entry / exit curves--bit-mot-duration-spinner,--bit-mot-easing-spinner,--bit-mot-loop-factor— the looping set- the unreduced
*-fullsources of both (see Motion and accessibility)
Opacity — --bit-opa-dis: the alpha of a disabled element that keeps its own colors, e.g. an image.
Overriding a token
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;
}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).
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):
-textholds ≥ 4.5:1 over every fill it is painted on — that is the base tier and the-darktier, whichBitToggleButton's checked state andBitPagination's selected page fill with while still drawing the on-color as text.-
The
-lighttier is a standalone tint; the on-color is never painted over it, so it is free to be as pale as it likes. -dis-textholds ≥ 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-priand--bit-clr-fg-secclear 4.5:1 overbg-pri,bg-secandbg-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-prikeeps ≥ 3:1 over all three (SC 1.4.11, non-text contrast).brd-secandbrd-terare the decorative stroke tiers — spinner tracks, menu separators, calendar grid lines — and are tuned for definition rather than to a contrast floor. Reach forbrd-priwhen 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
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 the theme touches on the same element — the semantic tier here, and the family tier below (a Shape.Radius.Control override carries the button, chip and selection radii that fall back to it) — 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);Family tokens
Between the primitives and the components sits a second alias tier, shipped in every bundle in
family-tokens.scss.
Every component takes its outer corner radius and its elevation from the family it belongs to, instead of from the single --bit-shp-brd-radius / --bit-shd-cal primitives:
/* radius per family - inputs, pickers, dropdown triggers, badges, pagination ... */
--bit-shp-radius-control /* → var(--bit-shp-brd-radius) */
--bit-shp-radius-surface /* → var(--bit-shp-brd-radius) cards, accordions, messages, images */
--bit-shp-radius-popup /* → var(--bit-shp-brd-radius) callouts, menus, dropdown lists, tooltips, snackbars */
--bit-shp-radius-dialog /* → var(--bit-shp-brd-radius) dialogs, modals */
/* radius per control sub-family - all three fall back to the control radius */
--bit-shp-radius-button /* → var(--bit-shp-radius-control) buttons and dialog actions */
--bit-shp-radius-chip /* → var(--bit-shp-radius-control) tags and the chips inside fields */
--bit-shp-radius-selection /* → var(--bit-shp-radius-control) the checkbox box */
/* elevation per surface family */
--bit-shd-card /* → var(--bit-shd-cal) */
--bit-shd-popup /* → var(--bit-shd-cal) */
--bit-shd-dialog /* → var(--bit-shd-cal) */
--bit-shd-sheet /* → var(--bit-shd-cal) panels and the responsive sheet form of menus/pickers */
--bit-shd-tooltip /* → var(--bit-shd-cal) */
--bit-shd-snackbar /* → none */
--bit-shd-appbar-top /* header, cast downwards, foreground-tinted so it survives dark palettes */
--bit-shd-appbar-bottom /* footer, cast upwards */
Under Fluent every family points at the same primitive, so overriding --bit-shp-brd-radius still re-rounds everything exactly as before. The families exist for the design systems that disagree: Material rounds a button (pill), a chip (8px), a card (12px), a menu (4px) and a dialog (28px) differently and lifts a menu, a dialog and a snackbar to different elevation levels; Cupertino capsules its buttons and chips over 8px fields, and separates hairlines from shadows. Those become one token per family instead of a fork of the component CSS — exactly what the packaged Material and Cupertino presets do; see Authoring your own preset below. Themable from C# via BitTheme.Shape.Radius.* and BitTheme.BoxShadow.{Card,Popup,Dialog,Sheet,Tooltip,Snackbar,AppBarTop,AppBarBottom}.
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 public API — you can find them by inspecting a component in the browser dev tools or in its SCSS sources.
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
The named palettes: what ships in the box, how to name one in C#, how to scope one to a subtree, and how to author your own.
The preset names
BitThemePresets exposes the built-in theme names, and BitExtraThemePresets (in the Bit.BlazorUI.Extras package, next to the stylesheet bundles that implement them) the Fluent 2, Material and Cupertino ones. Use them with SetThemeAsync, BitThemeName, or directly as the bit-theme attribute value.
// Bit.BlazorUI - the presets the core stylesheet carries
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
}
// Bit.BlazorUI.Extras - the design systems that ship as their own stylesheet bundle
public static class BitExtraThemePresets
{
public const string Fluent2 = "fluent2"; // alias for fluent2-light
public const string Fluent2Light = "fluent2-light";
public const string Fluent2Dark = "fluent2-dark";
public const string Material = "material"; // alias for material-light
public const string MaterialLight = "material-light";
public const string MaterialDark = "material-dark";
public const string Cupertino = "cupertino"; // alias for cupertino-light
public const string CupertinoLight = "cupertino-light";
public const string CupertinoDark = "cupertino-dark";
}The packaged design systems
The Fluent presets are self-contained in bit.blazorui.css. The Fluent 2, Material and Cupertino presets ship with the Bit.BlazorUI.Extras package as separate override-only bundles that re-value the global tokens under :root[bit-theme="…"] — their palettes come out of the same seed-derivation pipeline as the packaged Fluent colors (so they clear the same WCAG contrast gates), and their shape, size, typography, and motion values follow the published Fluent 2 design tokens, the Material 3 spec and the Apple HIG respectively. Link the bundle you use after the core stylesheet and set the bit-theme attribute; nothing else is involved:
Fluent 2 is the one preset that is a generation of the core theme rather than a different design language, so it reads as a retune:
- the control corner grows from 2px to 4px (
borderRadiusMedium), with an 8px dialog - the greys lose the warmth Fluent 1 gave them
- the brand moves to
#0F6CBD(brandWeb.80) - the flat callout shadow becomes Fluent 2's ambient + key pair (
shadow2…shadow64) - controls sit on the 24 / 32 / 40 height ladder, with 96px-wide labeled buttons and 16 / 20 / 24 glyphs
- labels go semibold
- the focus indicator becomes one
colorStrokeFocus2stroke (black on light, white on dark) instead of the role's own color
Fonts: the Material ramp is Roboto, which only Android and ChromeOS ship - the Bit.BlazorUI.Assets stylesheet (bit.blazorui.assets.css, see Getting Started) bundles a Roboto @font-face next to its Segoe UI one, so link it to get the Material type on every device. The Cupertino ramp is San Francisco, which is not redistributable: Apple devices render it natively and everything else falls back to Helvetica / Arial, as Apple's own sites do. The Fluent 2 ramp asks for Segoe UI Variable first (Windows 11 ships it) and falls back to the same Segoe UI chain the core Fluent theme uses.
<link rel="stylesheet" href="_content/Bit.BlazorUI/styles/bit.blazorui.css" />
<link rel="stylesheet" href="_content/Bit.BlazorUI.Extras/styles/bit.blazorui.material.css" />
<!-- or: _content/Bit.BlazorUI.Extras/styles/bit.blazorui.fluent2.css -->
<!-- or: _content/Bit.BlazorUI.Extras/styles/bit.blazorui.cupertino.css -->
<html bit-theme="material-dark">
Scoped regions work the same way (see Scoped presets below): <section bit-theme="material-dark"> re-skins just that subtree, packaged palette and all.
Letting the visitor pick among these is ready-made as the BitThemeSwitcher component in
the same Bit.BlazorUI.Extras package — the design system picker and the light/dark
toggle in this site's own header are that component. See the
ThemeSwitcher demo page for the full API.
Typed theme names
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);
// The Bit.BlazorUI.Extras design systems, on their own factory type
await _bitThemeManager.SetThemeAsync(BitExtraThemeName.MaterialDark);
// 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.
Accent color presets
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; // #6750A4 - Material 3's baseline primary
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.
Authoring your own preset
Every value that a design system decides once — the type ramp and weights, the corner radius of each component family, the elevation of each surface family, control heights, icon and checkbox sizes, the spinner stroke, entry/exit motion curves, the disabled alpha — is a global token that the component CSS reads and never hard-codes. That is what makes a Material or a Cupertino look a stylesheet rather than a fork: a preset is one :root[bit-theme="…"] block that re-values the tokens, and every component follows in step. The packaged Fluent 2, Material and Cupertino presets (see The packaged design systems above) are built exactly this way; the anatomy below is for when you want the same treatment for another design system — or a house variant of those three.
The tokens you will typically retune, with the Fluent defaults they replace:
/* acme-material.css - a Material-3-flavoured custom preset (the packaged material bundle is built the same way) */
:root[bit-theme="acme-material"],
:root [bit-theme="acme-material"] {
/* type: Roboto ramp; medium labels; 0.1px tracking on control labels */
--bit-tpg-font-family: Roboto, "Helvetica Neue", Arial, sans-serif;
--bit-tpg-fs-xs: 0.75rem; --bit-tpg-fs-sm: 0.875rem; --bit-tpg-fs-md: 1rem;
--bit-tpg-fw-semibold: 500; /* Roboto has no 600 - labels/titles fall to medium */
--bit-tpg-ctrl-letter-spacing: 0.00625rem;
--bit-tpg-ctrl-text-transform: none; /* Material 2 would say uppercase */
/* shape: 4px fields/menus, 12px cards, 28px dialogs, pill buttons, 8px chips, 2px checkbox */
--bit-shp-radius-control: 0.25rem;
--bit-shp-radius-popup: 0.25rem;
--bit-shp-radius-surface: 0.75rem;
--bit-shp-radius-dialog: 1.75rem;
--bit-shp-radius-button: var(--bit-shp-radius-full);
--bit-shp-radius-chip: 0.5rem;
--bit-shp-radius-selection: 0.125rem;
/* size: 40px controls, 18px checkboxes, 24px icons, the 52x32 switch, the 20dp slider handle */
--bit-siz-ctrl-sm: 2rem; --bit-siz-ctrl-md: 2.5rem; --bit-siz-ctrl-lg: 3rem;
--bit-siz-sel-sm: 1rem; --bit-siz-sel-md: 1.125rem; --bit-siz-sel-lg: 1.25rem;
--bit-siz-icon-sm: 1.125rem; --bit-siz-icon-md: 1.25rem; --bit-siz-icon-lg: 1.5rem;
--bit-siz-switch-w-md: 3.25rem; --bit-siz-switch-h-md: 2rem; --bit-siz-switch-thumb-md: 1.5rem;
--bit-siz-slider-thumb-md: 1.25rem;
/* elevation: level 1 cards, level 2 menus, level 3 dialogs, level 6 snackbars, flat tooltips */
--bit-shd-card: var(--bit-shd-1);
--bit-shd-popup: var(--bit-shd-2);
--bit-shd-dialog: var(--bit-shd-3);
--bit-shd-sheet: var(--bit-shd-3);
--bit-shd-tooltip: none;
--bit-shd-snackbar: var(--bit-shd-6);
/* motion: emphasized decelerate / accelerate */
--bit-mot-easing-decelerate: cubic-bezier(0.05, 0.7, 0.1, 1);
--bit-mot-easing-accelerate: cubic-bezier(0.3, 0, 0.8, 0.15);
/* state: 38% disabled alpha */
--bit-opa-dis: 0.38;
/* colors: the eight roles + fg/bg/brd families, exactly like the packaged palettes */
--bit-clr-pri: #6750A4;
/* ... */
}
A Cupertino-flavoured preset is the same file with different numbers — the numbers the packaged cupertino-* presets carry:
- SF Pro at 17px (
--bit-tpg-fs-sm: 1.0625rem), 590 semibold - 44px controls
- 8px field corners, with capsule buttons and chips (
--bit-shp-radius-button: var(--bit-shp-radius-full)) - 14px dialog corners
- the 51x31 UISwitch
- hairline separators (
--bit-shp-brd-width: 0.5px) with--bit-shd-popup: none
Load your own file as an extra stylesheet (or with BitExternalThemeLoader) and activate it with BitThemeManager.SetThemeAsync(BitThemeName.Custom("acme-cupertino")); the :root [bit-theme] twin selector makes it work as a scoped preset too.
The same knobs are reachable from C# for runtime composition, so a tenant can pick a design system the way it picks a brand color:
var material = new BitTheme
{
Typography =
{
FontFamily = "Roboto, sans-serif",
FontWeights = { SemiBold = "500" },
Control = { LetterSpacing = "0.00625rem" },
},
Shape =
{
Radius = { Control = "0.25rem", Popup = "0.25rem", Surface = "0.75rem", Dialog = "1.75rem",
Button = "var(--bit-shp-radius-full)", Chip = "0.5rem", Selection = "0.125rem" },
},
Size =
{
Control = { Sm = "2rem", Md = "2.5rem", Lg = "3rem" },
Selection = { Sm = "1rem", Md = "1.125rem", Lg = "1.25rem" },
Switch = { Width = { Md = "3.25rem" }, Height = { Md = "2rem" }, Thumb = { Md = "1.5rem" } },
SliderThumb = { Md = "1.25rem" },
},
BoxShadow = { Card = "var(--bit-shd-1)", Popup = "var(--bit-shd-2)", Dialog = "var(--bit-shd-3)", Snackbar = "var(--bit-shd-6)" },
Motion = { EasingDecelerate = "cubic-bezier(0.05, 0.7, 0.1, 1)" },
Opacity = { Disabled = "0.38" },
};
await BitThemeManager.ApplyBitThemeAsync(BitThemeUtilities.Merge(material, brandTheme));What stays per-component. A preset re-values tokens and nothing else: it never selects a component class, because anything a design system decides differently has to be a token the components already read — the packaged Fluent 2, Material and Cupertino bundles are pure :root[bit-theme="…"] token blocks, down to their pill button corners, their switch geometry and their slider handles. What stays per-component is geometry no design system has an opinion about (a clock dial, an avatar coin ladder, the loader shapes, the length of a slider): it is exposed as that component's own --bit-<cmp>-* variables (see Per-component variables) for an application to retune on the instances it wants, not for a theme to fork the library from.
Color derivation and contrast
One brand hex in, a contrast-checked palette out, plus the contrast tools the packaged palettes are gated on.
From one brand color
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).
This switcher ships ready-made as the BitAccentColorSwitcher component in the
Bit.BlazorUI.Extras package — swatches, persistence, dark/light re-derivation
and the first-paint (SSR / CDN cache) setup included. See the
AccentColorSwitcher demo page for the full API.
How the steps are derived
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);Contrast checks
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); // 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 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.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 --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);Density, layout and RTL
The layout-level tokens, and where reading direction belongs instead.
BitTheme.Layout drives the layout-level tokens. Set DensityScale for compact spacing and Breakpoints to align with your app media queries.
Density scales rhythm, not type: paddings, gaps, control heights (--bit-siz-ctrl-*) and selection sizes (--bit-siz-sel-*) follow the spacing unit times the density multiplier, while font sizes (--bit-tpg-fs-*) and glyph sizes (--bit-siz-icon-*) are rem values that follow the root font size only — the same split Material and Fluent make, so a compact UI keeps legible text.
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 and accessibility
The motion tokens, the one trap when theming durations, and the safety nets the stylesheets carry on their own.
Motion tokens
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
They are built into the Fluent stylesheets, and none of them needs anything from you:
- Reduced motion. Under
prefers-reduced-motion: reduce, all--bit-mot-duration*tokens collapse to0.01ms, so transitions effectively disable. The unreduced values stay available in the matching--bit-mot-duration*-fulltokens, which is also where you change a duration since it feeds both states. A single component can opt out with theForceAnimationparameter (it renders thebit-famclass, 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.01mswould render as flicker rather than as stillness. Instead--bit-mot-duration-spinnergoes to4sand--bit-mot-easing-spinnertolinear, while--bit-mot-loop-factorrises to3, stretching every other looping animation (the indeterminateBitProgressbar, the staggeredBitLoadingvariants) as a whole so the phase offsets between their parts survive. All three have-fullsources and are restored byForceAnimation, 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 tonone, and the focus ring uses the systemHighlightcolor so it stays visible. - Stronger contrast. Under
prefers-contrast: morethe 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-width1px → 2px) - the focus indicator strengthens (
--bit-shp-focus-ring-width2px → 3px with a matching offset bump, flowing through--bit-shd-focus-ringto every focused control) - the two subtlest tiers are promoted a step —
--bit-clr-brd-terand--bit-clr-fg-terare re-pointed at their-seccounterparts, which lifts the weakest pairs without touching the palette itself
- borders thicken (
The C# API
The runtime in C#: switch presets, override tokens globally or on a subtree, observe changes, and author a theme in code.
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 BitThemeProviderBitThemeProvider
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
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.
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}" }
}
}
}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/DisabledTextslots - shadow, with
Shadow.FocusRingand the per-surfaceShadow.Card/Popup/Dialog/… - z-index
- shape, with the focus-ring width/offset and
Shape.Radius.* - size (
BitCss.Var.Size) and opacity (BitCss.Var.Opacity) - spacing
- typography, with
Typography.FontSize.*,Typography.FontWeights.*,Typography.Control.* - motion (
BitCss.Var.Motion) - layout & breakpoints (
BitCss.Var.Layout) - the semantic aliases (
BitCss.Var.Semantic)
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.
JavaScript API
The same runtime for host pages, custom scripts, and anything that is not Blazor.
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 offonChange 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');Server-side rendering
Getting the right theme onto the first paint, on the client and on the server.
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.
In a Blazor Web App the root document is App.razor, and a MarkupString cannot open a tag that the same file has to close. Use BuildRootThemeAttributeMap there and splat it — it resolves identically, marker attributes included:
<html lang="en" @attributes="BitThemeSsr.BuildRootThemeAttributeMap(preference)" bit-theme-persist bit-theme-persist-cookie>
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.
End-to-end recipe
The pieces above assembled into one production setup.
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 complete list of source-breaking changes, and what is merely new.
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:
IsSystemInDarkMode()is nowIsSystemInDarkModeAsync(). A pure rename to follow the async naming convention — update call sites.- Manager methods return
ValueTask<string?>instead ofValueTask<string>.GetCurrentThemeAsync,SetThemeAsync,ToggleDarkLightAsync, andGetCurrentPersistedThemeAsyncreturnnullwhen JS interop is unavailable (prerendering or a disconnected circuit) — the previous signatures already producednullat 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; treatnullas "not resolvable yet". BitThemeManageris nowIAsyncDisposable. DI-resolved instances are disposed by the container automatically; if you construct one manually, dispose it. Calling any method after disposal throwsObjectDisposedException.- Typography variants were restructured.
Per-variant
FontFamilywas removed — the font family is global (Typography.FontFamily), withTypography.Inherit.FontFamilyas the per-element escape hatch.TextTransformandDisplaynow exist only on theButton,Overline, andInheritvariants, which are typedBitThemeLabelTypographyVariants(or a derivative) — soTypography.Button = new BitThemeTypographyVariants()no longer compiles; assign aBitThemeLabelTypographyVariantsor set properties on the existing instance instead. - Theme names are normalized.
SetThemeAsync("Dark")now writes the canonical lowercasedarkonto thebit-themeattribute (matching the packaged:root[bit-theme=…]selectors andBitThemeName). Compare returned theme names case-insensitively or against the canonical lowercase form. - 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.
- Dark-scheme derivation now lightens on interaction.
BitThemeColorDerivation.FillColorRoleFromMain(…, BitThemeColorScheme.Dark)(and thereforeBitThemeFactory.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
Every public name on this page, gathered in one place.
Bootstrap
AddBitBlazorUIServices()BitThemeAttributeNamesBitThemePresetsBitThemeName
Runtime
BitThemeManager:SetThemeAsync,ToggleDarkLightAsync,GetCurrentThemeAsync,GetCurrentPersistedThemeAsync,IsSystemInDarkModeAsync,ApplyBitThemeAsync,ClearBitThemeOverridesAsync,EnsureThemeNotificationsRegisteredAsyncBitThemeProvider(component):Theme,RootElement,ThemeName,Frozen, splatted attributesBitThemeNotifications.ThemeChangedBitThemeChangedEventArgs
Authoring
BitThemeBitThemeFactory(CreateLightTheme, CreateDarkTheme, CreateLightThemeFromSeed, CreateDarkThemeFromSeed)BitThemeSeedOptionsBitThemeUtilities(Merge, ToCssVariables, WithAlpha)BitThemeSerialization(Serialize, Deserialize)BitThemeDtcg(Export, Import)BitThemeColorDerivation(FillColorRoleFromMain)BitThemeAccentColorsBitThemeColorSchemeBitAccentColorPresetsBitThemeBreakpointDefaultsBitThemeDensityPresets(CreateCompactOverlay, CreateSpaciousOverlay)BitThemeColorContrast:GetContrastRatio,MeetsWcagAaNormalText,MeetsWcagAaLargeText,GetApcaContrast,MeetsApcaBodyText,MeetsApcaLargeText,MeetsApcaNonText,ApcaBodyTextLc/ApcaLargeTextLc/ApcaNonTextLc
Hosting
BitThemeSsr:InlineHeadScript,InlineHeadScriptBody,BuildInlineHeadScript(nonce),BuildRootThemeAttributes(preference, defaultTheme)BitThemeCookie.PreferenceCookieNamebit-theme-persist-cookie(attribute)bit-theme-view-transition(attribute)BitExternalThemeLoader:AttachStylesheetAsync,DetachStylesheetAsync
Authoring helpers in C#
BitCss.Class—bit-css-*utility class names (colors, shadows incl. per-surface, z-index, shape incl. the radius scalebit-css-shp-radius-*, focus ring)BitCss.Var—--bit-*custom property names
JavaScript
BitBlazorUI.Theme:get,set,toggleDarkLight,useSystem,applyTheme,clearAppliedTheme,onChange,isSystemDark,getPersisted,initBitBlazorUI.ExternalTheme:attach,detach
Feedback
Found a mistake, a gap, or something that could be clearer? Every page and every component is one click from its source.