Custom Theme Development

~25 min Intermediate

Build dark mode, multi-brand themes, and runtime theme switching with BptThemeBuilder. Export production-ready CSS/JSON/SCSS and apply themes at startup or at runtime via BptThemeLoader.

Home / Learning / Custom Theme Development
The whole picture BPT theming has three moving parts: a BptThemeConfig data model, the BptThemeBuilder editor UI that mutates it, and the BptThemeLoader runtime applicator that reads exported themes back into the app. Once you understand how the three fit together, the rest is naming colors.

Architecture: the three components

PieceTypeJob
BptThemeConfig POCO (Bpt.Components.Theming) Holds everything a theme controls: colors, metrics, typography, and per-component overrides. Serializes cleanly to JSON.
BptThemeBuilder Razor component Full-screen visual editor. Lets a designer (or admin) point-and-click their way to a complete BptThemeConfig, then export it.
BptThemeLoader Razor component (headless or dropdown) Reads a previously-exported theme (CSS/JSON/SCSS) and injects it into the page at runtime. Lives in your layout.
Why three pieces? Editing a theme is a heavyweight operation — drag-pickers, previews, sliders. Applying one at runtime is a lightweight operation — fetch a file, inject a <style>. Keeping them separate means your end users never pay the cost of loading the editor.

Step 1: Understand the theme model

Every theme is just a BptThemeConfig instance. The shape is stable and self-documenting:

public class BptThemeConfig { public string ThemeName { get; set; } = "Custom Theme"; public BptCssFramework Framework { get; set; } = BptCssFramework.Bootstrap; public BptComponentBaseStyle ComponentBaseStyle { get; set; } = BptComponentBaseStyle.Default; public BptThemeColors Colors { get; set; } = new(); public BptThemeMetrics Metrics { get; set; } = new(); public BptThemeTypography Typography { get; set; } = new(); // Per-component CSS property overrides. // Key = component name (e.g. "BptTextInput"), Value = dict of CSS prop → value. public Dictionary<string, Dictionary<string, string>> ComponentOverrides { get; set; } = new(); }

The four nested objects each carry a focused concern:

SectionExamplesEffect
ColorsPrimary, Background, BorderFocus, HoverBg, SelectedBgAll visual color tokens. Most theme work happens here.
MetricsBorderRadius, PaddingY, TransitionSpeed, ShadowPopupSizing/shape tokens. Bump BorderRadius to "1rem" for a softer look.
TypographyFontFamily, FontSizeMd, FontWeightBoldWhole-app text scaling and font swap.
ComponentOverrides"BptDropdown" → { "popup-bg": "#222", "selected-color": "#4FC3F7" }Per-component CSS variable overrides. Where component-specific polish lives.

The default values match Bootstrap's defaults exactly, so an "empty" theme is visually identical to running with no theme at all.

Step 2: Launch the visual editor

For 95% of theme work, you don't write code — you mount BptThemeBuilder and click. Use it inside an admin page, in an in-app "Appearance" modal, or as a designer's sandbox:

@page "/admin/theme" @using Bpt.Components.Theming <BptThemeBuilder FullScreen="false" Height="80vh" DefaultTheme="Light" OnThemeExportedRich="OnExported" /> @code { private async Task OnExported(BptThemeExport export) { // export.Format → BptThemeFormat.Css | Json | ScssZip // export.Text → string contents (CSS / JSON) // export.Bytes → raw bytes (ScssZip — a .zip) // export.Config → the raw BptThemeConfig instance // export.Filename → suggested filename, e.g. "MyBrand.theme.json" await ThemeStorage.PersistAsync(export); } }

DefaultTheme is one of "Light", "Dark", or "Nord" — the starting point. Importantly, the precedence chain is:

  1. InitialTheme parameter (programmatic config object)
  2. ThemeBytes parameter (e.g. resuming from a saved byte[])
  3. ThemeUrl parameter (fetch JSON/CSS/ZIP from server)
  4. Browser localStorage (built-in autosave between sessions)
  5. DefaultTheme fallback (Light/Dark/Nord)

The autosave is convenient during design — the editor remembers state across page reloads. In production, you'll almost always provide InitialTheme or ThemeUrl so users see your theme, not their last experiment.

Step 3: Export — pick the right format

The builder exports in three formats. The format you choose has real consequences:

FormatBest forTrade-off
JSON recommended App-controlled theming, runtime swaps, multi-tenant. Requires the BPT runtime to reapply. Lossless — preserves ComponentOverrides perfectly.
CSS Static deployment, drop-in <link> tag. Lossy on round-trip — component overrides become loose CSS rules; importing back loses the structured map.
SCSS ZIP Integrating into a SASS pipeline (Bootstrap customization). Same round-trip caveat as CSS. Larger payload (zipped variables + partial).
Why JSON wins for production CSS exports work great once, but you can't open them back in the editor and pick up where you left off without losing per-component overrides (popup colors, selected states, hover effects). JSON is the only format that round-trips losslessly.

Step 4: Apply a theme at runtime with BptThemeLoader

BptThemeLoader is the "apply" half of the system. It has two modes:

Mode A: Headless (single theme, applied silently)

Put it once in your MainLayout.razor. No UI — it just injects the theme styles when the layout renders.

<!-- MainLayout.razor --> <BptThemeLoader Url="/themes/acme.theme.json" /> <article class="content"> @Body </article>

For tenant-specific themes (SaaS apps), bind Url to a tenant identifier and the loader fetches on navigation:

<BptThemeLoader Url="@($"/api/tenants/{TenantId}/theme.json")" OnThemeChanged="OnThemeApplied" />
Mode B: Dropdown theme picker

Pass a Dictionary<string, string> of URL → display name and the component renders a BPT dropdown. Users pick a theme and it applies instantly with two-way binding:

<BptThemeLoader Themes="_themes" @bind-SelectedTheme="_currentTheme" Placeholder="Choose a theme…" Filterable="true" /> @code { private string _currentTheme = "/themes/light.json"; private Dictionary<string, string> _themes = new() { ["/themes/light.json"] = "Light", ["/themes/dark.json"] = "Dark", ["/themes/high-contrast.json"] = "High Contrast", ["/themes/acme-brand.json"] = "ACME Brand" }; }

Persist _currentTheme to localStorage (via a small JS interop) or to a user-profile API, and the picker doubles as a remembered preference.

Step 5: Build a dark mode

Dark mode is "swap most of the light colors for dark ones, swap the dark text colors for light ones, and re-tune the popup/hover backgrounds." The mechanical bits:

private static readonly BptThemeConfig DarkTheme = new() { ThemeName = "Dark", Framework = BptCssFramework.Bootstrap, ComponentBaseStyle = BptComponentBaseStyle.Default, Colors = new BptThemeColors { Primary = "#4FC3F7", Secondary = "#90A4AE", Success = "#66BB6A", Danger = "#EF5350", Warning = "#FFCA28", Info = "#26C6DA", Background = "#1A1A1A", BodyBg = "#0F0F0F", BodyColor = "#E0E0E0", TextColor = "#E0E0E0", TextMuted = "#9E9E9E", Border = "#3A3A3A", BorderFocus = "#4FC3F7", FocusShadow = "rgba(79, 195, 247, 0.30)", HoverBg = "#252525", SelectedBg = "#1E3A5F", ActiveBg = "#264C7A", DisabledBg = "#2A2A2A", DisabledText = "#555", TrackBg = "#2A2A2A", DangerBg = "#3A1A1C", BgLight = "#1F1F1F" }, Metrics = new BptThemeMetrics { ShadowPopup = "0 4px 16px rgba(0,0,0,0.6), 0 2px 4px rgba(0,0,0,0.4)" } };

Note the ShadowPopup override — light-mode shadows are nearly invisible on a dark background, so the opacity has to climb from 0.12 to 0.6. This is the kind of small detail the visual editor catches naturally; if you're hand-coding, watch for it.

// In MainLayout.razor: <BptThemeLoader Data="@_themeBytes" Format="BptThemeFormat.Json" /> @code { private byte[] _themeBytes = JsonSerializer.SerializeToUtf8Bytes(DarkTheme); }

Step 6: Multi-brand themes

For a multi-tenant SaaS, store one theme JSON per tenant and serve it from a tenant-aware endpoint. The shape:

// API endpoint: [HttpGet("/api/tenants/{tenantId}/theme.json")] public async Task<IActionResult> GetTheme(string tenantId) { var theme = await _themeService.GetForTenantAsync(tenantId); return File(JsonSerializer.SerializeToUtf8Bytes(theme), "application/json", $"{tenantId}.theme.json"); } // Persist a tenant's exported theme: public async Task SaveTenantThemeAsync(string tenantId, BptThemeExport export) { if (export.Format != BptThemeFormat.Json) throw new ArgumentException("Only JSON themes round-trip cleanly."); var json = export.Text ?? throw new ArgumentException("JSON export had no Text."); await _db.Themes.UpsertAsync(new ThemeRecord { TenantId = tenantId, Config = json, UpdatedAt = DateTime.UtcNow, UpdatedBy = _currentUserId }); }

On the client side, your layout pulls in the active tenant's theme on every navigation:

<BptThemeLoader Url="@($"/api/tenants/{TenantContext.Current.Id}/theme.json")" />
Caching Set Cache-Control: max-age=300 on the theme endpoint and prepend an ETag with the row's UpdatedAt tick. Themes change rarely; a five-minute cache reduces theme fetches dramatically without making admin edits feel sluggish.

Step 7: Runtime theme switching

Switching themes on the fly (user clicks "Dark" → page goes dark immediately, no reload) is just rebinding BptThemeLoader.Url or BptThemeLoader.Data:

<div class="d-flex gap-2 mb-3"> <button class="btn btn-outline-secondary" @onclick="@(() => SetTheme("/themes/light.json"))">☀️ Light</button> <button class="btn btn-outline-secondary" @onclick="@(() => SetTheme("/themes/dark.json"))">🌙 Dark</button> <button class="btn btn-outline-secondary" @onclick="@(() => SetTheme("/themes/contrast.json"))">🔆 High Contrast</button> </div> <BptThemeLoader Url="@_activeTheme" OnThemeChanged="OnThemeApplied" /> @code { private string _activeTheme = "/themes/light.json"; private async Task SetTheme(string url) { _activeTheme = url; await _userPrefs.SetThemePreferenceAsync(url); } private void OnThemeApplied(string url) { Console.WriteLine($"Theme applied: {url}"); } }

Setting _activeTheme triggers a parameter change on BptThemeLoader, which fetches the new file, parses it, and replaces the injected <style> block. The DOM stays mounted — no flicker, no scroll position lost.

Step 8: System preference detection

Honoring the OS-level dark-mode preference takes a few lines of JS interop. Add this to your layout's OnAfterRenderAsync:

protected override async Task OnAfterRenderAsync(bool firstRender) { if (!firstRender) return; var prefersDark = await JS.InvokeAsync<bool>( "eval", "window.matchMedia('(prefers-color-scheme: dark)').matches"); if (string.IsNullOrEmpty(_activeTheme) || _activeTheme == "/themes/auto") { _activeTheme = prefersDark ? "/themes/dark.json" : "/themes/light.json"; StateHasChanged(); } }

For change detection (user toggles dark mode in their OS while your app is open), wire a change listener on the MediaQueryList via a tiny JS module and call back via DotNetObjectReference — see the JS interop docs for the full pattern.

Step 9: Per-component overrides

The global color/metric tokens cover 80% of theming needs. The other 20% — "I want the dropdown's selected-item background to be a different blue than the buttons" — is where ComponentOverrides earns its keep.

Editing in the builder is point-and-click: pick a component from the sidebar, change a property. Editing in code:

theme.ComponentOverrides["BptDropdown"] = new Dictionary<string, string> { ["popup-bg"] = "#1E1E1E", ["popup-border"] = "#4FC3F7", ["selected-bg"] = "#264C7A", ["selected-color"] = "#FFFFFF", ["hover-bg"] = "#2A2A2A" }; theme.ComponentOverrides["BptTextInput"] = new Dictionary<string, string> { ["border-radius"] = "1.5rem", ["padding-x"] = "1rem" };

At export time these become CSS rules scoped to each component class (.bpt-dropdown, .bpt-text-input, etc.). They take precedence over the global tokens because they target a more specific selector — standard CSS cascade, no magic.

Step 10: Production checklist

ConcernRecommendation
Persistence format JSON. Always JSON. CSS is for "export and ship to a CDN once" workflows only.
Storage Filesystem (single tenant) or DB (multi-tenant). Themes are ~5-30 KB JSON — well below row-size limits.
Editor access Behind [Authorize(Roles="Administrator")]. Theme changes are global; ordinary users shouldn't see the editor.
Audit Log "user X exported theme Y at time Z" in OnThemeExportedRich. A surprise rebrand is hard to debug otherwise.
Cache headers Cache-Control: max-age=300 + ETag from UpdatedAt. Cheap and effective.
Versioning If your theme schema ever changes (new properties added to BptThemeConfig), older JSON files still load — extra properties are tolerated, missing ones get defaults.
SSR/prerender Theme injection happens client-side, so the first server-rendered paint uses the default tokens. To avoid the flash, server-render the theme's <style> directly into _Layout.razor alongside the loader.

Common gotchas

  • localStorage stickiness. The builder autosaves edits to localStorage so designers can iterate across reloads. In production this means users may see leftover edits from a designer who logged in earlier on the same browser. Provide InitialTheme, ThemeBytes, or ThemeUrl explicitly and that takes precedence.
  • CSS-export round-trip loss. Loading a CSS export back into the builder loses ComponentOverrides — the structured per-component data is gone, only loose CSS rules remain. If you persist designs and want re-editing later, use JSON.
  • FOUC on first paint. If you load themes from a remote URL, there's a window between page render and theme application where users see the default light styles. For server-rendered apps, server-render the theme <style> tag into _Layout.razor directly using BptThemeCssGenerator.
  • Bootstrap conflicts. If your app already pulls in bootstrap.min.css, that stylesheet's color tokens are independent of the BPT theme. To unify, set theme.Framework = BptCssFramework.Bootstrap (default) and the exported CSS overrides the relevant Bootstrap variables; otherwise BPT-themed inputs sit next to default-Bootstrap buttons. Awkward.
  • FontFamily inheritance. FontFamily = "inherit" means BPT components match whatever your page's <body> uses. Set it explicitly (e.g. "Inter, sans-serif") only when you want BPT controls to differ from surrounding text — otherwise leave it as inherit and control the font globally.
  • Theme drift between environments. Theme files are static assets, but unless they're in source control or backed up alongside the database, a server rebuild wipes them. Treat /themes/*.json as content, not code — back it up, version it.

An unhandled error has occurred. Reload 🗙

Rejoining the server...

Rejoin failed... trying again in seconds.

Failed to rejoin.
Please retry or reload the page.

The session has been paused by the server.

Failed to resume the session.
Please reload the page.