Web Components with Blazor
Key Points
- Two directions: (a) consume external Web Components inside
.razor, and (b) author Blazor components as Custom Elements for non-Blazor pages. - Web Components are framework-agnostic Custom Elements + Shadow DOM + HTML Templates — work in React, Vue, Angular, Blazor identically.
- Consuming: drop
<my-stencil-element>into a Razor file, pass attributes, listen to events with@on.... JS interop fills the gaps for property-only props. - Authoring:
RootComponents.RegisterCustomElement<T>("my-element")(still Experimental) emits a Custom Element. Each page that uses it boots a Blazor runtime. - Use case for authoring: design-system widget shared across React / Vue / legacy MVC pages by a .NET shop.
- Use case for consuming: leverage a vendor's WC library (Stencil, Lit, FAST) without rewriting it in Razor.
- Shadow DOM isolates styles — great for embedding, painful when global CSS should leak in.
- Compare: Razor Class Library beats WC inside a .NET shop; WC wins when consumers are not Blazor.
Concepts (deep dive)
What a Web Component is
A Custom Element is a regular HTML tag whose behavior is defined by customElements.define('my-element', class extends HTMLElement { … }). Three browser primitives:
┌──────────────────────────────────────────────┐
│ Custom Element → define a tag │
│ Shadow DOM → encapsulated subtree │
│ HTML Templates → reusable markup blocks │
└──────────────────────────────────────────────┘
Any framework can render <my-element foo="bar"></my-element>. Browser sees an element; Blazor doesn't care.
Direction A: consuming Web Components in Blazor
@page "/dashboard"
@inject IJSRuntime JS
<my-chart data-series="@_series"
@onchartclick="OnPoint">
</my-chart>
@code {
string _series = "[1,2,3]";
void OnPoint(EventArgs e) => /* … */;
}
Blazor treats unknown tags as raw elements. Attributes pass straight through. Custom events bind via @on{eventname} — Blazor adds DOM listeners for unknown event names.
Properties vs attributes
Web Components often expose properties (JS) that aren't reflected to attributes (HTML). Razor binds attributes, so for property-only props use JS interop:
<my-grid @ref="_grid"></my-grid>
@code {
ElementReference _grid;
protected override async Task OnAfterRenderAsync(bool first)
{
if (first)
await JS.InvokeVoidAsync("blazorWcHelpers.setProp",
_grid, "rows", _rows);
}
}
// wwwroot/wc-helpers.js
window.blazorWcHelpers = {
setProp: (el, name, value) => { el[name] = value; }
};
Custom event payloads
Define MyPickEvent and register it once via EventCallbackFactory extensions, or just take EventArgs and read event.detail from JS interop.
Direction B: authoring Custom Elements from Blazor
Microsoft.AspNetCore.Components.CustomElements (currently Experimental):
<!-- any plain HTML page, even React/Vue/MVC -->
<script src="_framework/blazor.webassembly.js"></script>
<blazor-counter increment-amount="5"></blazor-counter>
@* Counter.razor *@
<button @onclick="() => Count += IncrementAmount">@Count</button>
@code {
[Parameter] public int IncrementAmount { get; set; } = 1;
[Parameter] public int Count { get; set; }
}
Parameters become attributes. Two-way binding goes through DOM events.
Cost of authoring
Each page hosting a Blazor-authored Custom Element loads:
blazor.webassembly.js(~25 KB).- The .NET WASM runtime (~5-10 MB compressed; cached after first load).
- Your assemblies.
So one big design-system bundle is fine across many pages — but a single 50 KB widget on a static landing page is overkill.
Shadow DOM trade-off
class MyEl extends HTMLElement {
connectedCallback() {
this.attachShadow({ mode: 'open' }).innerHTML = `
<style>:host { display:block; padding:1rem; }</style>
<slot></slot>`;
}
}
| Shadow DOM on | Shadow DOM off |
|---|---|
| ✅ Style isolation | ❌ Global CSS leaks in |
| ✅ Embeds safely on hostile pages | — |
| ❌ Global theme variables don't reach inside (use CSS custom properties) | ✅ Theme inheritance free |
| ❌ Forms across shadow root quirky | ✅ Plain forms work |
Blazor-authored Custom Elements default to light DOM (no shadow root). Add a shadow root manually inside the host <script> if you want isolation.
Lifecycle alignment
| Web Component | Blazor |
|---|---|
connectedCallback | OnInitialized / OnAfterRender(firstRender:true) |
disconnectedCallback | Dispose |
attributeChangedCallback | parameter setter / OnParametersSet |
adoptedCallback | (rare) re-init |
When consuming a WC inside Blazor, you don't override these — the WC handles them. When authoring, Blazor shims them for you.
Browser support and polyfills
Native Custom Elements: Chrome, Edge, Safari, Firefox — all evergreen. Old IE11: forget it. For old Safari: @webcomponents/webcomponentsjs polyfill. In 2026 you almost never need polyfills.
Razor Class Library vs Web Components
| Decision driver | Pick |
|---|---|
| All consumers are Blazor | RCL — typed parameters, no JS interop tax |
| Mixed React + Vue + Blazor consumers | Web Components — universal contract |
| Need design-system across legacy MVC + new Blazor | WC in shared CDN bundle |
| Tight perf budget on a non-Blazor page | Stencil/Lit WC — Blazor runtime is heavy |
| Want full .NET libraries inside the widget | RCL or Blazor-authored WC |
Rule of thumb: stay in RCL inside the .NET shop; reach for WC at the boundary.
Authoring stack: Blazor vs Stencil vs Lit
| Tool | Best for | Output size |
|---|---|---|
| Lit | tiny widgets, DOM-close | tiny (~6 KB lib) |
| Stencil | mid-size design systems | small (~10-30 KB per element) |
| Blazor | want C# + .NET libs in the widget | heavy (.NET runtime) |
Most .NET shops authoring a public design system pick Stencil, then consume it in Blazor.
Code: correct vs wrong
❌ Wrong: setting a property as an attribute
✅ Correct: JS interop sets the property
❌ Wrong: forgetting @ref is null on first render
@code {
protected override async Task OnInitializedAsync()
=> await JS.InvokeVoidAsync(...); // _grid not assigned yet
}
✅ Correct: use OnAfterRenderAsync(firstRender)
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender) await JS.InvokeVoidAsync(...);
}
❌ Wrong: registering a CE per page
// Each lazy-loaded module re-registers same name → DOMException
builder.RootComponents.RegisterCustomElement<Foo>("my-foo");
✅ Correct: single registration on root bootstrap
Design patterns for this topic
Pattern 1 — "RCL inside .NET; WC at the boundary"
- Intent: typed sharing inside the shop, universal sharing outside.
Pattern 2 — "Stencil for authoring, Blazor for consuming"
- Intent: keep widget bundles tiny; use full Blazor for app pages.
Pattern 3 — "JS interop helper module for property-only props"
- Intent: central
blazorWcHelpers.js; no scatteredevals.
Pattern 4 — "Shadow DOM with CSS custom properties for theming"
- Intent: style isolation + themable via
--my-color: ....
Pattern 5 — "Single CDN bundle for the design system"
- Intent: one cached download across all consuming apps.
Pros & cons / trade-offs
| Direction | Pros | Cons |
|---|---|---|
| Consume WC in Blazor | Reuse vendor libs; no port | Property/attribute mismatch; JS interop |
| Author WC from Blazor | C# + .NET in widget | Runtime weight; per-page cost |
| Stay in RCL | Typed; tiny | Only works inside Blazor |
When to use / when to avoid
- ✅ Use WC consumption when you already pay for a Stencil/Lit design system.
- ✅ Use WC authoring when consumers are mixed (React, Vue, MVC) and you want C# inside.
- ❌ Avoid WC authoring for a small widget on a static page — Blazor runtime is overkill.
- ❌ Avoid WC inside an all-Blazor stack — RCL is strictly better.
- ❌ Avoid Shadow DOM if your hosting page must theme via cascading global CSS.
Interview Q&A
Q1. What are Web Components? Custom Elements + Shadow DOM + HTML Templates. Native browser primitives, framework-agnostic.
Q2. Can Blazor render a Web Component? Yes — drop the tag in .razor; Blazor passes attributes through.
Q3. What's the property-vs-attribute gotcha? Razor only binds attributes. Object/array props need JS interop to set on the DOM element.
Q4. How do you bind to a Web Component's custom event? @on{eventname} plus [EventHandler] registration if you want strongly-typed args.
Q5. Can you author a Custom Element from Blazor? Yes — RootComponents.RegisterCustomElement<T>("tag-name"). Currently Experimental.
Q6. Cost of a Blazor-authored Custom Element? Whole Blazor WASM runtime per consuming page (~5-10 MB cached after first load).
Q7. Shadow DOM trade-off? Style isolation vs no global CSS inheritance. Use CSS custom properties for theming across.
Q8. RCL vs Web Components? RCL inside Blazor (typed, no JS tax). WC at the boundary (universal, framework-agnostic).
Q9. Lifecycle mapping? connectedCallback ≈ OnInitialized; disconnectedCallback ≈ Dispose; attributeChangedCallback ≈ parameter setter.
Q10. Polyfills needed in 2026? Almost never — all evergreen browsers support CE natively.
Q11. Why not author every design-system in Blazor? Runtime weight; React/Vue consumers don't want a .NET runtime per page.
Q12. Multiple Blazor-authored CEs on one page? Single Blazor runtime serves all of them — register at bootstrap.
Gotchas / common mistakes
- ⚠️ Object props passed as attributes — silently get
ToString()'d. - ⚠️
@refused too early — null until first render. - ⚠️ Re-registering the same custom element name — DOMException.
- ⚠️ Shadow DOM blocks parent styles — surprising for theming.
- ⚠️ Form events across shadow root — composed: true needed.
- ⚠️ Using Blazor-authored CE on tiny pages — runtime weight.
- ⚠️ Forgetting
[EventHandler]for typed custom events.