How to style Blazor + FluentUI components in this repo without Tailwind. The "isolation-first hybrid" convention — CSS isolation by default, global design tokens, and two documented exceptions for FluentUI shadow-DOM and light-DOM children. Use when authoring or restyling any .razor component, choosing where CSS lives, or styling a FluentUI component.

Maintained in timewarp-architecture · Canonical file: SKILL.md

Install

npx skills add TimeWarpEngineering/timewarp-architecture --skill blazor-css-strategy

Or copy the SKILL.md into your agent's skills directory.


Blazor CSS Strategy (isolation-first hybrid)

We do not use Tailwind. The design system is hand-written plain CSS built on global design tokens. This skill is the standard for where component CSS lives and how to scope it.

It exists because Blazor CSS isolation has two hard walls that bite the moment a component composes FluentUI:

The rules

  1. Default = Blazor CSS isolation (Foo.razor.css). An isolated component MUST render a native HTML root (<section>/<button>/<div>/<span>), never a FluentUI/child component as its root. This is what keeps isolation working.
  2. Brand tokens are global in web-spa/wwwroot/css/tokens.css as CSS custom properties. Consume them with var(--twe-*). Tokens are the single source of truth for color, type scale, radius, elevation, and status palette — never hard-code these in component CSS.
  3. Exception A — styling inside a FluentUI primitive (shadow DOM): use ::part() + CSS custom properties only. Nothing else works.
  4. Exception B — styling a FluentUI light-DOM child you can't wrap, or runtime-dynamic CSS: own a scope handle and write a co-located <style> scoped to it; pass the handle to the FluentUI component via Class=. No ::deep. No inline style= (inline styles are prohibited under strict-CSP / locked-down browsers — reserve Style=@Value for genuinely dynamic per-instance values only).
    • Multi-instance component → scope by .@(Id) (the Id from the state base component, see Tiers below).
    • Singleton (e.g. the layout/shell) → a fixed namespaced root class (.twe-shell).

Two base-class tiers (keep them separate)

Canonical in-repo example (Tier-2 / Exception B)

web-spa/components/TimeWarpPage.razor (the app shell) does this — it renders FluentLayout / FluentNav / FluentTextInput (light-DOM children, Wall A) and styles them via a co-located <style> scoped to a fixed root class .twe-shell (the shell is a singleton, so a fixed class rather than .@(Id)):

<FluentLayout Class=@($"{Id} twe-shell")> … <FluentNav Class="twe-nav"/> … </FluentLayout>

<style>
  @(@"
    .twe-shell .twe-nav { background: var(--twe-paper-2); border-right: 1px solid var(--twe-rule); }
    .twe-shell .twe-appbar__search fluent-text-input { width: 100%; }
  ")
</style>

.twe-shell .twe-nav reaches the FluentNav's light-DOM root (a plain descendant selector from the Id'd ancestor — no ::deep, no wrapper div). Use a verbatim string @(@"…") so CSS braces are literal; only use the interpolated @($@"… {{ }} …") form when you need {Id}.

Tier-1 example (leaf, isolation)

@* Card.razor *@
<section @attributes="Attributes" class="@CssClass">
  <div class="twe-card__body">@ChildContent</div>
</section>
/* Card.razor.css */
.twe-card {
  background: var(--twe-paper);
  border: 1px solid var(--twe-rule);
  border-radius: var(--twe-radius);
}

Decision quick-reference

Situation Approach
Leaf component with a native root Isolation (*.razor.css)
Need a brand color / size / radius var(--twe-*) from tokens.css
Style a FluentUI light-DOM child (FluentStack, FluentNav, splitter…) Exception B: Class=@($"{Id} …") + .{Id} in co-located <style>
Singleton layout/shell Exception B with a fixed root class (.twe-shell)
Change a FluentUI primitive's internals (button bg, text color) Exception A: ::part() + CSS variables
Truly dynamic per-instance value Style=@Value (sparingly only)
Anything Never global.css dumping ground; never inline style= as the system

Notes