Skip to content

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:

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

<!-- Keep <meta name="theme-color"> with the page (browser chrome / PWA status bar) -->
<html bit-theme-system bit-theme-color-meta>...</html>

<!-- ...reading a token other than the default --bit-clr-bg-pri -->
<html bit-theme-system bit-theme-color-meta="--bit-clr-bg-sec">...</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).

Browser chrome (PWA status bar)

The one piece of an app's appearance CSS cannot reach is the browser chrome: the status bar of an installed PWA, the address bar on mobile. It is painted from <meta name="theme-color">, and bit-theme-color-meta keeps that tag equal to a color of the live page - --bit-clr-bg-pri by default, or whatever custom property the attribute names (bit-theme-color-meta="--bit-clr-bg-sec" for an app whose pages sit on the secondary surface). Every theme-color tag in the document is rewritten, so a first-paint pair split by media="(prefers-color-scheme: …)" is corrected on both when the visitor pins a theme against their OS; a document with no tag at all gets one.

The color is read back from the page rather than mapped from the theme name, because a name carries no color: each design system paints its own surfaces, an app's stylesheet may re-declare the tokens, and an accent (BitAccentColorSwitcher) re-derives the whole palette - surfaces included - as an overlay on <body>. That is also why this is not something to write in an onChange callback: with bit-theme-view-transition the bit-theme attribute is written a frame after the change is announced, so a callback would still read the outgoing palette. What the library watches instead is the DOM the color comes from.

It stays off unless asked for, since an app that hardcodes its own tag must keep the tag it wrote. The same switch is available from JavaScript as init({ themeColorMeta: true }) (or a property name, or false to turn it off), and BitBlazorUI.ThemeColorMeta.sync() forces a re-read after a change the observer cannot see, such as a stylesheet swapped through ExternalTheme.

It cannot cover the first paint: the browser paints the chrome from the tag in the served HTML, long before any script or stylesheet has loaded. Give the tag a sensible starting color there (a per-theme literal, or the pair of media-qualified tags) exactly as before - this keeps it honest from the first frame the app can compute it.

Resolution order

Mirrored in the SSR script and in 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

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 the BitText variants
  • 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(opens in a new tab).

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 of a control that draws it in the default color. A control that asks for a focus color of its own - an invalid input, or any component with a --bit-<Component>-focus-color - composes the same two layers itself, since a composite token substitutes its var()s where it is declared and could not read a color set further down the tree. Replacing this token therefore re-shapes only the default-colored rings; to restyle every ring, set the width, the offset and the --bit-clr-*-focus colors, which reach both
  • --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 to control

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 - the floor under a labeled button: auto under Fluent and Cupertino; Material sets 64px, Fluent 2 96px
  • --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-width until 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
  • --bit-siz-dialog-max-width - the width a dialog stops growing at on its own

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 / center under Fluent; Cupertino stacks them full width with column / 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-font-family-mono - the monospaced family, for the text whose characters have to line up in a column (BitText's Monospace)
  • --bit-tpg-fs-{2xs,xs,sm,md,lg,xl,2xl,3xl,4xl} - the font-size scale, 10…32px; component size classes read sm→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 BitText variants h1..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 *-full sources 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. 0.5 under Fluent, Fluent 2 and Cupertino; Material sets the 0.38 that M3 names for the disabled state. A text-bearing control does not use it - it recolors through the --bit-clr-*-dis tokens instead.

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(opens in a new tab) 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(opens in a new tab) 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

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(opens in a new tab):

--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(opens in a new tab). 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.

The components that document theirs carry a CSS variables table on their own page - ActionButton and Checkbox among them - naming each variable in full (--bit-ActionButton-hover-background, --bit-Checkbox-checked-background), with its default and what it changes. Those are read off the root with a fallback and never declared by the component, so they inherit: set one on :root, on any ancestor, or on a single instance. For the rest, inspect a component in the browser dev tools or read its SCSS source.

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 every packaged theme name - the Fluent family the core stylesheet carries, and the Fluent 2, Material and Cupertino ones whose stylesheet bundles ship with the Bit.BlazorUI.Extras package. There is no second, package-specific table: a package that ships presets adds them to this same class, as the extension point below describes. 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 adds ITS design systems to that same class, as extension members.
// You never name the container below - you read BitThemePresets.MaterialDark.
public static class BitThemePresetsExtensions
{
    extension(BitThemePresets)
    {
        public static string Fluent2       => "fluent2";          // alias for fluent2-light
        public static string Fluent2Light  => "fluent2-light";
        public static string Fluent2Dark   => "fluent2-dark";
        public static string Material      => "material";         // alias for material-light
        public static string MaterialLight => "material-light";
        public static string MaterialDark  => "material-dark";
        public static string Cupertino     => "cupertino";        // alias for cupertino-light
        public static string CupertinoLight => "cupertino-light";
        public static string CupertinoDark  => "cupertino-dark";
    }
}

One difference comes with that: a name contributed from another package is a static property, not a const, so it cannot be a case label, an attribute argument or a default parameter value. Use if / == where a switch over constants was. Everything else - SetThemeAsync, the attribute value, comparisons - is unchanged.

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 colorStrokeFocus2 stroke (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.

The preset extension point

A package that ships a stylesheet contributes its presets to the core theming APIs instead of publishing a parallel set of its own - which is how Bit.BlazorUI.Extras puts Fluent 2, Material and Cupertino on BitThemePresets, BitThemeName and BitThemeSurfaces, and how your app puts its own preset there too. There are two halves to it.

The names are extension members on the core types (C# 14 extension(T) blocks), so BitThemePresets.MaterialDark and BitThemeName.MaterialDark are reached from the same types as the Fluent ones. Nothing but a @using Bit.BlazorUI is needed to see them.

An extension member needs C# 14 on the reading side too, and it is a property rather than a const. So for a project that compiles at an older language version - the default on net8.0 and net9.0, both of which Bit.BlazorUI.Extras still targets, where reading one is CS9202 - and for a case label or an attribute argument on any target, the same nine presets are also on BitExtraThemePresets (consts), BitExtraThemeName and BitExtraThemeSurfaces. They say exactly what the extension members say.

What C# needs to know about a preset before its CSS can answer - today the two first-paint surfaces - is registered with BitThemePresetRegistry. BitThemeSurfaces is a live view over that registry, so a preset registered by anyone is immediately part of the one table BitThemeHead reads:

// In your app's Program.cs, for a preset of your own
BitThemePresetRegistry.Register(new BitThemePreset
{
    Name = "acme-dark",                  // the bit-theme token, validated as BitThemeName.Custom validates one
    BackgroundPrimary = "#101418",       // --bit-clr-bg-pri, for the theme-color tag at first paint
    BackgroundSecondary = "#161B21",     // --bit-clr-bg-sec, if your pages sit on the secondary surface
});

BitThemeSurfaces.BackgroundPrimary["acme-dark"];   // "#101418"
BitThemePresetRegistry.Find("acme-dark")!.IsDark;  // true - the same "ends with dark" rule as every other layer
BitThemePresetRegistry.All;                        // every preset, yours and the packaged ones, by name

Registration is thread-safe, and your app's word is final whatever the order. Register replaces: registering a name again is how you re-skin a packaged preset you have overridden in your own CSS. Remove takes a preset out of the first-paint table, for an app that offers only some of the packaged presets. A package adds its presets with TryRegister, which only fills gaps - it skips a name you have registered and a name you have removed, even when the package loads long after your Program.cs ran - so neither decision is ever undone behind your back.

// Re-skin: your CSS overrides material-dark's page background, so first paint should too
BitThemePresetRegistry.Register(new BitThemePreset { Name = BitExtraThemePresets.MaterialDark, BackgroundPrimary = "#000000" });

// Narrow: this app does not offer Cupertino, so keep it out of the first-paint lookup
BitThemePresetRegistry.Remove(BitExtraThemePresets.CupertinoDark);

The registry is the first-paint table and nothing more: Remove does not take a preset out of a BitThemeSwitcher (its items are its own DesignSystems / Items parameters) or out of any stylesheet. And because BitThemeHead defaults to the whole table, an app that references Bit.BlazorUI.Extras carries the packaged presets' colors in its first-paint script even if it never links their bundles - a few dozen bytes; Remove them, or hand ThemeColors a map of your own, if you want it exact. The maps themselves are ordinal: a lookup matches the normalized token exactly, while BitThemePresetRegistry.Find / Contains / Remove normalize what they are given.

When a package's presets register. A package registers from a module initializer, which the runtime runs before the first access to anything in its assembly - so naming one of its presets in C# is itself what wires it up, and there is no setup call to forget. AddBitBlazorUIExtrasServices is a method of that assembly, so an app that sets its services up with it has already made that first access, and has the packaged surfaces before its first host page renders.

The one gap is an app that does none of that - links an Extras bundle, writes the bit-theme token as a bare string, and never mentions the package in C# - where nothing loads the assembly and the first-paint color falls back to the scheme's light/dark surface (everything else is CSS and unaffected). Note that a const from BitExtraThemePresets is inlined by the compiler and so does not load it either. Name the preset through BitThemePresets / BitExtraThemeName, read the surfaces through BitExtraThemeSurfaces, or call BitExtraThemeRegistration.Register() once at startup.

Typed theme names

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

// Core factories
await _bitThemeManager.SetThemeAsync(BitThemeName.FluentDark);
await _bitThemeManager.SetThemeAsync(BitThemeName.System);

// The Bit.BlazorUI.Extras design systems, on that same struct
await _bitThemeManager.SetThemeAsync(BitThemeName.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.

Why a preset declares a token twice. A custom property's var() references are substituted at the element that declares it, and descendants inherit the already-substituted value. So a token whose value is an alias - --bit-shp-radius-control, --bit-shd-popup, --bit-sem-*, --bit-mot-duration, the composed --bit-shd-focus-ring - would freeze the document's value inside a scoped region if it were declared on :root alone. The core stylesheet therefore re-declares every alias tier on :root [bit-theme] as well, which re-runs the substitution against that region's own primitives. Two rules follow for a preset of your own: pair every :root[bit-theme="x"] selector with a :root [bit-theme="x"] twin, and re-declare a token you rely on even when its value is unchanged from the one you inherit - if that value is an alias, inheriting it is not the same as re-resolving it. The packaged presets are pinned to both rules by a contract test.

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

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.

This holds under every packaged preset, not only Fluent. Fluent 2, Material and Cupertino all fix their control geometry on an 8px grid - 40dp Material buttons, the 44pt iOS tap target, Fluent 2's 32px medium control - so each preset writes those measurements as spacing multiples rather than as absolute lengths, and the density multiplier reaches them exactly as it does under Fluent. A preset of your own should do the same wherever its geometry lands on the grid; where it does not, an absolute length is fine and simply opts that one measurement out of density.

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 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, read by every focused control's ring, whether it comes from --bit-shd-focus-ring or is composed in a color of its own)
    • 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.

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

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(opens in a new tab) 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/DisabledText slots
  • shadow, with Shadow.FocusRing and the per-surface Shadow.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.

// For the browser chrome itself, prefer bit-theme-color-meta (see "Browser chrome" below) -
// it reads the live palette, which a callback cannot do while a view transition is pending.
BitBlazorUI.Theme.onChange((newTheme, oldTheme) => {
    document.body.classList.toggle('is-dark', newTheme.includes('dark'));
});

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',
    // true for --bit-clr-bg-pri, or name another property; same as bit-theme-color-meta
    themeColorMeta: '--bit-clr-bg-sec',
    onChange: (newTheme, oldTheme) => {
        console.log(`theme: ${oldTheme} -> ${newTheme}`);
    }
});

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 start of <head> in your root document (e.g. Components/App.razor) - before any stylesheet, but after <meta charset>, which the browser only honors inside the document’s first 1024 bytes:

<head>
    <meta charset="utf-8" />
    @((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).

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.

The whole host page in two calls

The attributes above and the head content are the two halves of one setup, and each has a helper that owns every name involved - so a host page states what it wants rather than spelling out attributes, scripts and colors. BitThemeSsrOptions carries the document-level switches into the attribute builder, and <BitThemeHead> emits the head half: the first-paint script, and the theme-color tag in the surface color of the theme this very response renders.

@{
    var preference = HttpContext.Request.Cookies[BitThemeCookie.PreferenceCookieName];
    var themeOptions = new BitThemeSsrOptions
    {
        Persist = true,
        PersistCookie = true,
        ViewTransition = true,
        ThemeColorMeta = BitCss.Var.Color.Background.Secondary.Main, // or true for the page background
        LightTheme = BitThemePresets.Fluent2Light,
        DarkTheme = BitThemePresets.Fluent2Dark,
    };
}

<html lang="en" @attributes="BitThemeSsr.BuildRootThemeAttributeMap(preference, themeOptions)">
<head>
    <meta charset="utf-8" />
    <BitThemeHead PersistedPreference="@preference"
                  LightTheme="@themeOptions.LightTheme"
                  DarkTheme="@themeOptions.DarkTheme"
                  ThemeColors="@BitThemeSurfaces.BackgroundSecondary" />
    ...

Why the colors are a table. Everything else about the theme is read off the live page, but the browser paints its chrome from the served HTML - before any stylesheet exists - so that one value has to be a literal. BitThemeSurfaces has the two surfaces (BackgroundPrimary, BackgroundSecondary) of every registered preset - the packaged ones, each pinned to its stylesheet by a contract test, plus whatever your app registered. An app with its own palette passes its own map; a theme the map does not carry falls back to the light or dark entry - and, failing that, to any entry of the map on the same side - by the same "ends with dark" rule the stylesheets use. Pass EmitThemeColor="false" to keep a tag the page writes itself.

Hand both halves the same preference and the same light / dark pair: that is what makes the tag and the document agree about which theme was painted. The client can still resolve a third theme the server never saw - it follows the OS, or its localStorage is newer than the cookie this response was rendered from - so BitThemeHead always emits a small script that reads the resolved attribute back and corrects the tag, still before first paint, writing nothing when the two already agree.

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:

  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

Every public name on this page, gathered in one place.

Bootstrap

  • AddBitBlazorUIServices()
  • BitThemeAttributeNames
  • BitThemePresets
  • BitThemeName
  • BitThemePreset / BitThemePresetRegistry: Register, Remove, Find, Contains, All
  • BitExtraThemePresets / BitExtraThemeName / BitExtraThemeSurfaces (the same Extras presets without extension members)

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), BuildRootThemeAttributes(preference, options) and the …AttributeMap pair
  • BitThemeSsrOptions - the document-level setup the attribute builders emit
  • BitThemeHead - the head half: first-paint script + theme-color tag
  • BitThemeSurfaces: BackgroundPrimary, BackgroundSecondary
  • BitThemeCookie.PreferenceCookieName
  • bit-theme-persist-cookie (attribute)
  • bit-theme-view-transition (attribute)
  • bit-theme-color-meta (attribute) - and BitThemeAttributeNames, the C# names of all of them
  • 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 scale bit-css-shp-radius-*, focus ring)
  • BitCss.Var - --bit-* custom property names

JavaScript

  • BitBlazorUI.Theme: get, set, toggleDarkLight, useSystem, applyTheme, clearAppliedTheme, onChange, isSystemDark, getPersisted, init
  • BitBlazorUI.ThemeColorMeta: enable, disable, sync
  • BitBlazorUI.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.