v1.0.0

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, matcher and format take functions, and the only way to turn a string into one is eval. 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, not disabledDaysOfWeek — 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 &quot;. 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-week usable 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();