Frameworks
BMX components are custom elements, so the browser renders them. That is what lets the same markup work in every framework — and it is why the wrappers below contain no component code at all: they render the tag and bridge properties and events, which is why one wrapper serves both the demo and the commercial runtime.
What has been run, and what has not
Every code sample on this page for React, Angular and Vue is the shape the
generated wrappers produce. The Blazor and server-rendered Razor
sections are different: they were written against a real .NET project rather
than from recollection, and that project is in the repository at
Docs/verification/dotnet. It asserts each claim in a browser and reports
nineteen passes. Where a sentence below says something surprising about .NET,
there is a row in that project that measured it.
The one rule
Simple values through attributes; anything else as a property — or as JSON in an attribute, if a property is not available to you.
An HTML attribute can only ever be a string, and a JavaScript array is not one. Where you can assign a property, do:
element.items = [{ id: 'save-as', label: 'Save as…' }];
The React, Angular and Vue wrappers do this for you, and that is most of what they are for.
But a great deal of the world cannot assign a property. A Razor page, a Django template, a Rails view, an htmx swap and a hand-written HTML file all render text, and an attribute is the only channel they have. So every list and lookup property in the library also reads the JSON spelling of itself, and a list of plain strings or numbers also reads a comma-separated one:
<bmx-split-button items='[{"id":"csv","label":"CSV"},{"id":"pdf","label":"PDF"}]'>
Export
</bmx-split-button>
<bmx-date-picker disabled-dates="2026-12-25,2026-12-26" disabled-days-of-week="0,6">
</bmx-date-picker>
Both spellings are read once, as the element upgrades, into the array or object
the property is declared as — so whatever wrote it, element.items reads back
as an array afterwards.
Three things this does not extend to, and each fails in a way worth knowing:
- A callback cannot come from an attribute.
validator,uploader,matcherandformattake functions, and the only way to turn a string into one iseval. The library refuses: the property behaves as though it were never set, and a warning naming the element and the property is written to the console once. Assign these from script. - Malformed JSON is not silently empty. It is an empty list plus one console warning naming the element and the property. Check the console before believing an attribute did nothing.
- A property whose name is more than one word needs the dash spelling.
disabled-days-of-week, notdisabledDaysOfWeek— the HTML parser lowercases attribute names, so the camelCase spelling never arrives. The dash spelling is what the API reference lists.
React
React 19 passes non-string props to custom elements as properties, so the elements work directly. The wrapper is still worth using: it gives you typed props, typed event handlers, and identical behaviour on React 18, which passes everything as an attribute.
import { BmxButton, BmxButtonGroup } from 'bmx-webcomponents-react';
export function Toolbar({ onDelete }) {
return (
<BmxButtonGroup label="Row actions" attached variant="outline" tone="neutral">
<BmxButton>Edit</BmxButton>
<BmxButton tone="danger" confirm="Click again to delete" onBmxActivate={onDelete}>
Delete
</BmxButton>
</BmxButtonGroup>
);
}
For the imperative API, take a ref. Each wrapper exports its element type alongside the component:
import { BmxButton, type BmxButtonElement } from 'bmx-webcomponents-react';
const button = useRef<BmxButtonElement>(null);
await button.current?.setFocus();
Keep your data stable
The wrapper compares by reference before assigning. An array rebuilt inline on every render is a new reference every render, so hoist it or memoise it:
const items = useMemo(() => [{ id: 'csv', label: 'CSV' }], []);
Angular
The Angular wrapper ships as source: copy wrappers/angular/src/ into your
application. Each component is a directive that attaches to the element
itself, so the markup is the markup every other framework writes, there is no
extra wrapper node in the DOM, and — because a directive matches the tag —
Angular treats it as a known element, so CUSTOM_ELEMENTS_SCHEMA is not needed.
import { BMX_DIRECTIVES } from './bmx';
@Component({
standalone: true,
imports: [...BMX_DIRECTIVES],
template: `
<bmx-button tone="danger" [confirm]="confirmText" (bmxActivate)="remove()">
Delete
</bmx-button>
`,
})
export class RowComponent {}
Square brackets bind a property, which is what array and object inputs need. A plain attribute binding would stringify them.
Without the directives the elements still work: add CUSTOM_ELEMENTS_SCHEMA so
Angular stops warning about an unknown element, and keep binding non-string
values with square brackets.
Event listeners in the directive are bound outside Angular's zone and re-enter it only when something is actually subscribed, so a component firing events rapidly does not trigger a change-detection pass per event.
Source rather than a library, because a prebuilt Angular library has to be compiled against one Angular major version, and a customer on a different major cannot use it. These are decorated classes with no logic worth compiling ahead of time, so letting your Angular compile them means they keep working across upgrades that would strand a prebuilt library.
Vue
Tell Vue which tags are custom elements, and it will pass properties correctly:
// vite.config.js
export default {
plugins: [vue({ template: { compilerOptions: { isCustomElement: tag => tag.startsWith('bmx-') } } })],
};
<script setup>
const items = [{ id: 'csv', label: 'CSV' }];
</script>
<template>
<bmx-split-button :items="items">Export</bmx-split-button>
</template>
:items binds a property, which is what an array needs.
Events need the wrapper, or a listener of your own
Vue hyphenates a listener name before it reaches a DOM element, so @bmxSelect
on the bare tag listens for bmx-select and never fires — silently, which is
worse than an error. Either use the wrapper, which re-emits every event through
Vue's own emit, where the name is matched as written:
<script setup lang="ts">
import { BmxSplitButton } from 'bmx-webcomponents-vue';
</script>
<template>
<BmxSplitButton :items="items" @bmxSelect="onSelect">Export</BmxSplitButton>
</template>
...or bind the listener yourself on the bare element:
<script setup>
const menu = ref();
onMounted(() => menu.value.addEventListener('bmxSelect', onSelect));
</script>
<template>
<bmx-split-button ref="menu" :items="items">Export</bmx-split-button>
</template>
Blazor
Blazor renders the DOM itself and writes bindings as attributes, so the two
questions are how to give a component a list and how to get an event back. Both
have exact answers, and both were run — see Docs/verification/dotnet.
The elements need nothing
Load the runtime in App.razor and use the tags. A custom element rendered by
Blazor upgrades and draws normally, simple values bind as they look, and — this
is worth stating because it is the thing people expect to break — a Blazor
re-render leaves the attributes a component reflected onto itself alone. The
components stay styled across a re-render.
<bmx-button tone="danger" variant="outline">Delete</bmx-button>
Lists: JSON in the markup, or IJSRuntime
From markup, serialise and let the component read it:
<bmx-split-button items="@ItemsJson">Export</bmx-split-button>
@code {
private static string ItemsJson => JsonSerializer.Serialize(new[]
{
new { id = "csv", label = "CSV" },
new { id = "pdf", label = "PDF" },
});
}
From C#, assign the property through a three-line helper:
window.bmxInterop = { setProperty: (element, name, value) => { element[name] = value; } };
await JS.InvokeVoidAsync(
"bmxInterop.setProperty",
element,
"items",
(object)new[] { new { id = "csv", label = "CSV" } });
The (object) cast is load-bearing and its absence is silent.
InvokeVoidAsync takes params object?[], and array covariance means a
string[] or an anonymous-type array binds as that params array rather than
as one item in it. Without the cast the component is handed the first element
of your list instead of the list. No exception, no warning — just a menu with
one entry.
Events: a JavaScript initializer, not a script tag
This is the part that is easy to get wrong, and it fails without a single error message. Three pieces have to line up.
One — an event-args class and an [EventHandler] declaration, in a .cs
file. The static class must be named exactly EventHandlers: that is the
name the Razor compiler looks for, and a class called anything else is not
found. Nothing fails at build time; the directive compiles and the handler never
runs.
using Microsoft.AspNetCore.Components;
namespace YourApp;
public sealed class BmxActivateEventArgs : EventArgs
{
public string? Via { get; set; }
public bool? Pressed { get; set; }
}
[EventHandler("onbmxactivate", typeof(BmxActivateEventArgs),
enableStopPropagation: true, enablePreventDefault: true)]
public static class EventHandlers
{
}
Two — the registration, in wwwroot/{YOUR ASSEMBLY NAME}.lib.module.js.
Blazor discovers that file by name and calls what it exports.
export function afterWebStarted(blazor) {
blazor.registerCustomEventType('bmxactivate', {
browserEventName: 'bmxActivate',
createEventArgs: event => ({
via: event.detail?.via ?? null,
pressed: event.detail?.pressed ?? null,
}),
});
}
A Blazor Web App exports afterWebStarted. Blazor Server and standalone
WebAssembly export afterStarted instead. Registering from an inline
<script> before Blazor.start() — which is what most tutorials show, and
which is correct for those two hosting models — does nothing in a Web App,
because its interactive runtime starts after the page's own scripts have run.
The symptom is exact and gives you nothing to search for: the event is raised,
it bubbles, a plain document listener hears it, @onclick on the very same
element works, and no C# handler runs.
browserEventName is the event as the component emits it, camelCase included.
That was measured rather than assumed.
Three — the handler, on the element or on any ancestor. Both work, so a list of rows can carry one handler on the container.
<bmx-button @onbmxactivate="Remove">Delete</bmx-button>
@code {
private void Remove(BmxActivateEventArgs args) => Console.WriteLine(args.Via);
}
Project the detail; never pass it through. createEventArgs returns an
object that crosses into .NET as JSON, and some BMX details carry an
originalEvent, which is a DOM object — JSON.stringify of one yields
{"isTrusted":false}. Name the fields you want, as above.
ASP.NET MVC and Razor Pages
A server-rendered page is a stricter test than Blazor, because nothing but the browser ever touches the element: there is no renderer to assign properties and no event registration. It works, and the whole library is reachable — the JSON and comma-separated spellings in The one rule exist for exactly this.
An MVC view and a Razor Page are the same Razor and behave identically.
public string ItemsJson => JsonSerializer.Serialize(
items,
new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase });
<bmx-split-button items="@Model.ItemsJson">Export</bmx-split-button>
<bmx-select label="Tags" multiple
values="@Model.SelectedTags"
options="@Model.OptionsJson"></bmx-select>
<bmx-date-picker label="Delivery"
disabled-dates="@Model.BankHolidays"
disabled-days-of-week="0,6"></bmx-date-picker>
Set PropertyNamingPolicy to camelCase. .NET serialises PascalCase by default
and the components read camelCase keys, which is a mismatch that produces an
empty component rather than an error.
Razor will HTML-escape the quotes in that JSON into ". That is correct
and the browser un-escapes it when it parses the attribute.
Three Razor mechanics worth having in one place:
@@is a literal at-sign.data-at="@@bmx"reaches the DOM as@bmx.- Razor will not accept a self-closed custom element.
<bmx-button />is a build error; write the closing tag. - A hyphenated attribute Razor has never heard of is passed through exactly as
written, which is what makes
disabled-days-of-weekusable at all.
Events are the browser's here, not the framework's. Every BMX event bubbles and
is composed, so one listener on document sees all of them wherever they were
raised — including from inside a shadow root:
document.addEventListener('bmxChange', event => console.log(event.detail));
Svelte, Solid, Alpine, htmx, and no build step at all
Nothing special is needed. Load the runtime and use the tags. Elements upgrade whenever they appear in the document, so markup rendered later — by a templating engine, an htmx swap, a jQuery plugin — works without being told.
<script src="/assets/bmx-components.min.js"></script>
<bmx-button tone="primary">Save</bmx-button>
Svelte passes non-string values as properties to unknown elements, so lists
work directly, and on:bmxChange binds the event as written.
Solid needs prop: for a property — prop:items={items} — because its
compiler writes an attribute otherwise.
Alpine and htmx are markup, so they use the JSON and comma-separated
spellings above. An htmx swap that brings new <bmx-*> markup into the page
upgrades it without being told; there is nothing to re-initialise after a swap.
jQuery, or no framework: $('bmx-select')[0].options = [...] for a
property, .on('bmxChange', …) for an event.
Server-side rendering
Every document and window access in the library is guarded, so importing the
components in a Node render pass is safe. They render on the client; nothing
throws on the server.
TypeScript
The package publishes element interfaces, so document.querySelector is typed:
import type { HTMLBmxButtonElement } from 'bmx-webcomponents';
const button = document.querySelector<HTMLBmxButtonElement>('bmx-button');
await button?.setFocus();