I spent the last few months building BlazOrbit, a component library for Blazor. It's not the first of its kind —MudBlazor, Radzen and Blazorise already exist— so I had to answer a hard question from the start: why does this need to exist?
The answer turned out to be a set of architectural decisions I want to share, because each one taught me something about building UI frameworks that I didn't know before I started.
Lesson 1: CSS classes don't scale in libraries
The standard pattern in component libraries is class toggling:
<button class="btn btn-primary btn-lg">Click me</button>
I don't think this is developer-friendly, and there are several points worth making:
-
Name collisions: Your
.btn-primarywill clash with Bootstrap, Tailwind, or the consumer's own CSS. - Combinatorial explosion: A button can be small, large, disabled, loading, outlined, filled, errored, full-width, with ripple, without ripple... The number of class combinations grows exponentially.
-
No override surface: If the consumer wants all buttons to be purple, they have to override every modifier combination or use
!important.
I experimented with BEM, then utility-first CSS, then CSS-in-JS. None of them solved the problem — or maybe none of them just clicked for me.
What I chose
Using a custom <bob-component> tag and communicating state through data attributes:
<bob-component
data-bob-component="button"
data-bob-size="large"
data-bob-disabled
style="--bob-inline-background: #6200ee;"
>
<button>Click me</button>
</bob-component>
Being aware that engines are more optimized for class-based selection, I analyzed the potential cost [1]. It came out to roughly ~0.1ms for a standard DOM. In exchange:
- Readability: It's easier to read
[data-bob-size="small"]in CSS than a tangle of dynamic classes. - Consistency: Component state (active, disabled, etc.) makes more sense as a data attribute.
- Decoupling: Logic from styles. Which might sound contradictory if you think about it, since data attributes do affect styles. The point is that the attribute marks a dynamic state, while the class identifies/defines the design group.
- Testing: Easier automated testing.
Along the same chain of decisions, there's the question of how to standardize CSS files.
In the end, the most practical approach was having the global CSS bundle select on [data-bob-component="button"] and [data-bob-size="large"]. And the scoped component CSS declares private variables:
[data-bob-component="button"] {
--_button-background: var(--bob-inline-background, var(--palette-primary));
}
[data-bob-component="button"] button {
background: var(--_button-background);
}
This gives consumers three override levels that compose cleanly:
-
Instance: pass the
BackgroundColorparameter -
Theme: redefine
--palette-primaryin their CSS -
Global CSS: target
[data-bob-component="button"]directly
No !important. No specificity wars. No class leakage.
Lesson 2: Reflection is fast if you only do it once
After deciding to group components based on IHas* interfaces (IHasSize, IHasBorder, IHasPrefix...), which let me unify component rendering and standardize their implementation, I got worried about performance. Each component render needs to know which IHas* interfaces it implements in order to emit the right attributes. Doing reflection per render would be catastrophic.
The solution was a [Flags] enum cache:
[Flags]
private enum ComponentFeatures : uint
{
Variant = 1u << 0,
Size = 1u << 1,
Density = 1u << 2,
Loading = 1u << 4,
// ... 22 flags
}
On the first encounter with a component type, we inspect its interfaces and store the bitmask in a ConcurrentDictionary<Type, TypeInfo>. Subsequent renders of the same component type read the cached flags. The expensive path runs once per type, per process.
And I went a bit further. Not all attributes change at the same frequency. Size and Density change when the parent reconfigures the component. Loading, Disabled, and Error toggle on every user interaction. Rebuilding the full attribute set on every StateHasChanged would be wasteful.
So I split the pipeline:
-
BuildStyles (runs on
OnParametersSet): full rebuild, reflection cache hit, all attributes computed. -
PatchVolatileAttributes (runs on
BuildRenderTree): only rewrites the 7 high-frequency state attributes. No dictionary allocation. No reflection.
In DEBUG builds we instrument the pipeline with IBOBPerformanceService. On a regular PC, PatchVolatileAttributes costs ~0.02ms per component. Fast enough not to worry about.
Lesson 3: A lot of customization is not enough customization
I obviously put a lot of effort into making things customizable:
- Variable exposure
- Theme creation. Dark and light mode...
But even so, I decided (again) to go to war and design an extensible variant system.
In most libraries, a variant is an if branch inside the .razor file:
@if (Variant == "primary")
{
<button class="btn-primary">...</button>
}
else if (Variant == "secondary")
{
<button class="btn-secondary">...</button>
}
Or it just modifies a CSS class.
This is simple and obvious. It's also fundamentally closed. If the consumer wants a Gradient variant, they have to fork the library or wrap the component.
I tried several approaches to open it up:
- IIncrementalGenerator: Which generated variants based on an enum. This implementation was actually in the first component library I built, which was really more of a prototype for the current BlazOrbit. It worked well, but it was a nightmare to extend due to the sheer amount of generated code. Plus it hid how everything worked.
- Inheritance: Doesn't fit with defining Variant as a component attribute.
So I came up with something I'm still not entirely sure is brilliant, a fever dream, or a monumental blunder — but the truth is that in BlazOrbit you can use a registry pattern to define component variants. Just like registering a service.
Func<TComponent, RenderFragment> delegates are stored in a singleton dictionary, keyed by (ComponentType, VariantType, Name). The component core then retrieves them.
It looks like this:
builder.Services.AddBlazOrbitVariants(v =>
{
v.ForComponent<UIButton>()
.AddVariant(MyVariants.Gradient, component => __builder =>
{
<button class="gradient" @attributes="component.ComputedAttributes">
@component.ChildContent
</button>
});
});
The variant receives the live component instance. It can access ComputedAttributes, parameters and state. It can restructure the DOM entirely. And it lives in the consumer's code, not the library's.
The trade-off is that the component's .razor file becomes a thin dispatcher:
@if (VariantRegistry.GetTemplate(GetType(), Variant, this) is RenderFragment template)
{
@template
}
At first this felt strange —the component doesn't own its own markup— but it unlocks something no class-based component library offers: open variants.
I'm not entirely sure whether it's viable in static SSR contexts — if anyone wants to try... :)
But hey, it's out there.
Lesson 4: The build pipeline and its dependencies must not leak to consumers
BlazOrbit's CSS bundle is generated by a custom build tool I created ad-hoc: CdCSharp.BuildTools. To be honest it's somewhat tightly coupled to how BlazOrbit uses it, but hey — it's open source and it's out there. It's attribute-based and lets you generate files at build time and run Node sequences (npm install, tsc, vite), all driven by what it generates itself.
With that I transpile TypeScript modules (I didn't want to give it up), generate the bundles. And everything cleans up when you Clean the project.
It's transparent to the consumer — no delegation to Node, npm, vite or any equivalent. And at the same time it offers all those capabilities during development.
I split the pipeline:
-
Maintainer build (
BlazOrbit.Dev.targets): runs BuildTools, regenerates assets. -
Consumer build (
BlazOrbit.targetsinside the.nupkg): does nothing. The pre-packaged CSS and JS are distributed asstaticwebassetsinside the package. The Razor SDK serves them automatically.
Lesson 6: Multi-targeting is not free
Supporting .NET 8 and .NET 10 from the same source code sounds simple: just add <TargetFrameworks>net8.0;net10.0</TargetFrameworks>.
Reality is messier:
- The Razor compiler behaves differently across versions.
-
Microsoft.AspNetCore.Components.Webhas breaking changes in parameter validation. - Build tools run on
net10.0but must generate assets that work on both.
The worst bug I hit was a race condition in multi-targeting builds. The BeforeBuild target that invokes BuildTools was running in both the outer build and the inner ones simultaneously, causing file lock conflicts on package.json. The fix was a condition:
Condition="'$(IsCrossTargetingBuild)' == 'true' OR '$(TargetFrameworks)' == ''"
This ensures BuildTools runs once per outer build, not once per TFM.
Where we are now
BlazOrbit is at 1.0.0-preview.N. The architecture is stable. The API surface almost too. But still open to changes for now. I'm collecting feedback, adjusting things, and adding a few things to the MVP.
Also waiting for contributors (ahem...) — if any brave soul with a taste for code feels like it, you know where to find us.
And if you're building a component library, I hope these lessons save you a few weeks of experimentation. If you're a Blazor developer, that is.
BlazOrbit is open source (MIT). Issues and discussions are open. Any kind of feedback is welcome.
Give it a try and let me know:
Other topics I may cover in a future post.
- CSS standards audit tests across three domains: DOM-emitted, CSS-declared, and library constants
- Integration vs. unit tests for component libraries. Spoiler: Integration testing across two scenarios: Server and Wasm
- Extending some native Blazor components vs. full customization
- Decision: The official documentation resides in the source code.
Top comments (0)