Skip to content
Shiny.NET

Theming

Shiny Controls ship a central, Material 3 style theming system shared by MAUI and Blazor. A theme is a set of design tokens — color roles, shape, elevation, typography, density, borders, state and spacing. The core packages define the token contract and a built-in Basic theme; additional themes (Ocean, Material, Terminal, Aurora) install as separate NuGet packs.

MAUI — the same ShinyButton page under all five packs. Nothing on it is styled by hand; every colour comes from the tokens the installed pack defines.

Basic Ocean Material Terminal Aurora
The button gallery under the built-in Basic theme The button gallery under the Ocean pack The button gallery under the Material pack The button gallery under the Terminal pack The button gallery under the Aurora pack

Blazor — the token gallery. The colour roles, and the same page after swapping the pack stylesheet and flipping to dark.

Basic Ocean Aurora, dark Terminal, dark
Colour role swatches under the Basic theme The same swatches under the Ocean pack The Aurora pack's dark ramp The Terminal pack's dark ramp
Typography scale Shape, elevation, spacing
The fifteen named type steps, each with its own size, weight, line height and tracking Corner radii, the six elevation levels and the spacing ramp

Color alone cannot make two themes look genuinely different. The neutral ramp barely moves between packs — a tone-98 near-white has no room to carry a hue — so a palette-only theme leaves most controls looking identical and only the accents shift.

So a Shiny theme also defines its personality. Every block is optional; omit one and the shared default applies.

Block What it controls
shape Corner geometry — a scale, or absolute corners overrides. The single biggest visual lever: square versus pillowy reads as a different framework before any color registers.
typography fontFamily / displayFamily / monoFamily, scale, weightOffset, trackingOffset, lineHeightScale.
elevation style — shadow, flat, outline or glow — plus intensity (how dark), softness (how large and diffuse) and tint.
density A scale on the spacing ramp and control metrics, plus absolute controlHeight / controlHeightSmall / rowHeight.
border The thin / medium / thick stroke ramp.
state hover / focus / pressed / dragged layer opacities.

intensity and softness are separate on purpose: “big soft halo at low opacity” and “tight dark shadow” are different looks that a single knob collapses into one dim, shrunken shadow.

The built-in packs are picked to be visibly unalike:

Pack Personality
Basic The neutral default, shipped in the core packages.
Material M3 purple, Roboto, generously rounded, tonal shadows.
Ocean Teal, soft pillowy corners, airy spacing, shadows you have to look for.
Terminal Phosphor green, square, dense, monospace throughout, hairline rings instead of shadows.
Aurora Violet/cyan, rounded, bold-weighted, and everything raised glows rather than casting a shadow.

There are two layers, and only the small one is meant to be edited.

The authoring layer is the ~35 values a theme actually states. The names are the ones every modern design system uses, so there is nothing to learn:

Surfaces background foreground card popover muted muted-foreground
Semantic primary secondary accent destructive success info warning caution critical — each with a -foreground
Lines border input ring shadow-color
Knobs radius density text-scale border-width font-sans font-display font-mono, plus weight / tracking / line-height offsets

The derived layer is the contract the controls consume: the 55 Material colour roles and the shape, type, spacing, density and elevation ramps. It is expressed over the authoring layer rather than restated:

--shiny-color-primary: var(--shiny-primary);
--shiny-color-primary-container: color-mix(in oklab, var(--shiny-primary) 22%, var(--shiny-background));
--shiny-shape-corner-large: calc(var(--shiny-radius) * 1.3333);
--shiny-type-body-large-size: calc(16px * var(--shiny-text-scale));

Which is what makes one edit ripple — on Blazor you can restyle the whole control set from your own stylesheet, with no JSON, no generator and no package:

/* your app.css, linked after the Shiny theme */
:root {
--shiny-primary: #5B21B6; /* container tint, surface tint and focus ring follow */
--shiny-radius: 4px; /* every corner in the ramp follows */
--shiny-density: 0.9; /* control heights and the spacing scale follow */
}
.shiny-theme-dark { --shiny-primary: #A78BFA; }

Seeds are still the fastest way to get a coherent palette from one brand colour — the Material tonal machinery builds a full scheme and the authoring layer is lifted out of it — but they are no longer the only way in. A theme can state authoring values outright in a tokens block, and what it states wins.

The platform assets are emitted from there:

  • MAUI → C# ResourceDictionary classes; controls bind colors with SetDynamicResource(…, ShinyThemeKeys.Color.X). MAUI has no color-mix at paint time, so the derivations are computed when the theme is generated — in oklab, the same space the CSS mixes in, so both hosts land on the same colour.
  • Blazor → a CSS file: the authoring layer, then the derived contract written over it. A theme pack is a delta — its palette, its knobs, and only what cannot be expressed over them.

Swapping the theme restyles the entire control suite at once — including a live light/dark switch.

Every ink/ground pair in all five packs clears WCAG AA in both schemes, and a test suite holds them there, so a derivation change cannot quietly make a container unreadable.

Basic is applied automatically by UseShinyControls(). Install a pack and select it:

// dotnet add package Shiny.Maui.Controls.Themes.Ocean
builder.UseShinyControls(cfg => cfg.UseOceanTheme());
// or .UseMaterialTheme() / .UseTerminalTheme() / .UseAuroraTheme() / .UseBasicTheme()

Switch at runtime — light/dark follows the OS automatically and hot-swaps:

ShinyThemeManager.SetTheme(new OceanTheme());
Application.Current.UserAppTheme = AppTheme.Dark; // flips to the dark scheme live

Explicitly setting a control color (e.g. Fab.FabBackgroundColor) still overrides the theme.

The core Basic stylesheet ships in Shiny.Blazor.Controls. Link it in index.html:

<link href="_content/Shiny.Blazor.Controls/css/shiny-theme.css" rel="stylesheet" />

Install a pack and link its stylesheet after the core one (it overrides :root):

<!-- dotnet add package Shiny.Blazor.Controls.Themes.Ocean -->
<link href="_content/Shiny.Blazor.Controls.Themes.Ocean/css/shiny-theme-ocean.css" rel="stylesheet" />

Dark mode follows the OS by default. Force it by adding shiny-theme-dark (or shiny-theme-light) to <html> or any container — the tokens cascade to that subtree.

Category Examples
Color (M3 roles) primary, on-primary, primary-container, surface, on-surface, outline, …
Color (Shiny status) success, info, warning, caution, critical (each with on- and -container)
Shape corner-small (8), corner-medium (12), corner-large (16), corner-full
Border border-thin, border-medium, border-thick
Elevation elevation-0 … elevation-5
State hover-opacity, focus-opacity, pressed-opacity, dragged-opacity
Density density-scale, density-control-height, density-row-height, density-touch-target
Type font-family / -display / -mono, type-scale, and body-large, title-medium, label-small, … (size / line-height / weight / tracking)
Spacing spacing-0 … spacing-8 (0, 4, 8, 12, 16, 24, 32, 48, 64 at density 1)

The touch-target token is deliberately never scaled by density — shrinking the hit area below the platform minimum is an accessibility bug, not a design choice.

  1. Use the Theme Composer, or copy themes/basic.json and edit the authoring tokens.
  2. Add whichever personality blocks you want to differ. themes/shiny-theme.schema.json documents every field with its default, and editors complete it from the $schema reference at the top of each theme file.
  3. Run dotnet run --project tools/ShinyThemeGen to regenerate both platforms.
  4. For a new pack, add the two project files (model them on the Ocean pack) and register them in Shiny.Controls.slnx / Build.slnf.