Custom Theme Development
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.
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
| Piece | Type | Job |
|---|---|---|
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. |
<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:
| Section | Examples | Effect |
|---|---|---|
Colors | Primary, Background, BorderFocus, HoverBg, SelectedBg | All visual color tokens. Most theme work happens here. |
Metrics | BorderRadius, PaddingY, TransitionSpeed, ShadowPopup | Sizing/shape tokens. Bump BorderRadius to "1rem" for a softer look. |
Typography | FontFamily, FontSizeMd, FontWeightBold | Whole-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:
InitialThemeparameter (programmatic config object)ThemeBytesparameter (e.g. resuming from a savedbyte[])ThemeUrlparameter (fetch JSON/CSS/ZIP from server)- Browser
localStorage(built-in autosave between sessions) DefaultThemefallback (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:
| Format | Best for | Trade-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). |
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")" />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
| Concern | Recommendation |
|---|---|
| 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
localStorageso 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. ProvideInitialTheme,ThemeBytes, orThemeUrlexplicitly 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.razordirectly usingBptThemeCssGenerator. - Bootstrap conflicts. If your app already pulls in
bootstrap.min.css, that stylesheet's color tokens are independent of the BPT theme. To unify, settheme.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/*.jsonas content, not code — back it up, version it.