Button (Web Component)
<bpdm-button>, the bpdm/ui Button as a standards-based custom element. Works in React, Angular, Vue, PHP, Rails, Elixir/Phoenix LiveView, Django, WordPress and plain HTML, with native <form> submit.
<bpdm-button> is the bpdm/ui Button delivered as a framework-agnostic Web
Component. It renders a real <button> inside its shadow root (so focus and
keyboard behaviour stay native), is themed by the @bpdm/tokens CSS variables,
and is form-associated: type="submit" / type="reset" drive the
surrounding native <form> exactly like a built-in button, with no extra JS.
Setup
Register the element once, and load the tokens so it picks up a theme. After that you just write the tag, in a template, a component, or straight HTML.
<!doctype html>
<html>
<head>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@bpdm/tokens/tokens.css" />
<script type="module" src="https://cdn.jsdelivr.net/npm/@bpdm/wc/dist/button/bpdm-button.js"></script>
</head>
<body>
<bpdm-button variant="primary">Save changes</bpdm-button>
</body>
</html>import '@bpdm/wc/button';
import '@bpdm/tokens/tokens.css';
export function App() {
return (
<bpdm-button variant="primary" onClick={() => console.log('saved')}>
Save changes
</bpdm-button>
);
}React 19 passes attributes and listens to events on custom elements natively. For TypeScript, declare the tag once (see TypeScript).
import { Component, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import '@bpdm/wc/button';
@Component({
selector: 'app-root',
template: `<bpdm-button variant="primary" (click)="save()">Save changes</bpdm-button>`,
schemas: [CUSTOM_ELEMENTS_SCHEMA], // allow custom element tags
})
export class AppComponent {
save() {
console.log('saved');
}
}{{-- once, in your layout <head> --}}
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@bpdm/tokens/tokens.css" />
<script type="module" src="https://cdn.jsdelivr.net/npm/@bpdm/wc/dist/button/bpdm-button.js"></script>
{{-- anywhere in a view --}}
<form method="POST" action="/save">
@csrf
<bpdm-button type="submit">Save changes</bpdm-button>
</form><link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@bpdm/tokens/tokens.css" />
<script type="module" src="https://cdn.jsdelivr.net/npm/@bpdm/wc/dist/button/bpdm-button.js"></script><form phx-submit="save">
<bpdm-button type="submit" phx-update="ignore" id="save-btn">Save changes</bpdm-button>
</form>Because <bpdm-button> emits a native submit, LiveView's phx-submit fires
without a hook. Add phx-update="ignore" so LiveView's DOM patching leaves the
element's shadow content alone.
Variants
The variant attribute sets the semantic colour: primary, secondary,
success, info, warning, help, destructive, or contrast.
<div style="display:flex; flex-wrap:wrap; gap:0.75rem">
<bpdm-button variant="primary">Primary</bpdm-button>
<bpdm-button variant="secondary">Secondary</bpdm-button>
<bpdm-button variant="success">Success</bpdm-button>
<bpdm-button variant="info">Info</bpdm-button>
<bpdm-button variant="warning">Warning</bpdm-button>
<bpdm-button variant="help">Help</bpdm-button>
<bpdm-button variant="destructive">Destructive</bpdm-button>
<bpdm-button variant="contrast">Contrast</bpdm-button>
</div>Appearances
The appearance attribute sets the style: solid (default, filled), outline
(bordered, transparent fill), and ghost (no border or fill). It composes with
every variant.
<bpdm-button appearance="solid">Solid</bpdm-button>
<bpdm-button appearance="outline">Outline</bpdm-button>
<bpdm-button appearance="ghost">Ghost</bpdm-button>Sizes
The size attribute offers sm, md (default), and lg for text buttons, plus
square iconSm / icon / iconLg for icon-only buttons, and none to opt out.
<bpdm-button size="sm">Small</bpdm-button>
<bpdm-button size="md">Medium</bpdm-button>
<bpdm-button size="lg">Large</bpdm-button>Shape
shape="round" makes a text button a pill, or an icon button a circle.
<bpdm-button shape="round">Pill button</bpdm-button>
<bpdm-button shape="round" variant="success">Success</bpdm-button>Loading
Add the loading attribute to show a spinner, mark the element aria-busy, and
block activation while a request is in flight. Toggle it from your own code.
<bpdm-button id="save" type="submit">Save changes</bpdm-button>
<script type="module">
const btn = document.getElementById('save');
btn.closest('form')?.addEventListener('submit', async (e) => {
e.preventDefault();
btn.loading = true;
await fetch('/save', { method: 'POST' });
btn.loading = false;
});
</script>Native form submit
This is the headline feature: <bpdm-button> is a form-associated custom
element, so type="submit" and type="reset" work inside a real <form>,
the browser (and your server, Rails, Laravel or LiveView) sees an ordinary
submit. No click handlers, no JS glue.
<form method="POST" action="/subscribe">
<select name="plan">
<option value="starter">Starter</option>
<option value="pro">Pro</option>
</select>
<bpdm-button type="submit">Subscribe</bpdm-button>
<bpdm-button type="reset" variant="secondary" appearance="outline">Reset</bpdm-button>
</form><form phx-submit="subscribe">
<select name="plan">
<option value="starter">Starter</option>
<option value="pro">Pro</option>
</select>
<bpdm-button type="submit" phx-update="ignore" id="subscribe-btn">Subscribe</bpdm-button>
</form>Theming
The element reads the @bpdm/tokens CSS custom properties, which cross the shadow
boundary. Switch themes by setting data-theme on an ancestor, or override single
tokens in your own CSS:
/* one of the four built-in themes */
:root { /* paper (default) */ }
[data-theme='charcoal'] { /* dark */ }
/* or nudge a token, applies to every bpdm element inside */
.brand-area {
--bpdm-primary: #0ea5e9;
--bpdm-primary-foreground: #ffffff;
--bpdm-radius: 0.75rem;
}API
Attributes & properties
| Name | Type | Default | Description |
|---|---|---|---|
variant | primary | secondary | success | info | warning | help | destructive | contrast | primary | Semantic colour. |
appearance | solid | outline | ghost | solid | Filled, bordered, or text-only. |
size | sm | md | lg | iconSm | icon | iconLg | none | md | Text sizes, square icon sizes, or none. |
shape | default | round | default | Token radius, or round (pill / circle). |
type | button | submit | reset | button | Native button behaviour inside a <form>. |
disabled | boolean | false | Removes interaction and dims the element. |
loading | boolean | false | Spinner + aria-busy; blocks activation. |
loading-label | string | Loading | Screen-reader text while loading (property: loadingLabel). |
Slots
| Slot | Description |
|---|---|
| (default) | The button label. |
start | Leading adornment (e.g. an icon), before the label. |
end | Trailing adornment, after the label. |
Parts
| Part | Description |
|---|---|
base | The inner <button>, styled with ::part(base). |
spinner | The loading spinner. |
Events
Standard DOM events bubble from the element, listen for click as usual. When
disabled or loading, activation is swallowed so no click escapes.
Accessibility
- Renders a native
<button>in the shadow root withdelegatesFocus, so Tab, Enter / Space, and the focus ring are the platform's own. - Icon-only buttons have no text, so set an
aria-labeldescribing the action. loadingsetsaria-busy="true"and keeps the label for context;disabledexposesaria-disabledand drops the element from the tab order.- Motion honours
prefers-reduced-motion(the press spring and spinner calm down). - Logical CSS throughout, so an icon + label pair mirrors under
dir="rtl".
Framework interop
TypeScript
Declare the tag once so JSX / templates type-check:
import type { BpdmButton } from '@bpdm/wc/button';
declare global {
interface HTMLElementTagNameMap {
'bpdm-button': BpdmButton;
}
}Angular
Add CUSTOM_ELEMENTS_SCHEMA to any component (or module) that uses the tag.
Vue
Tell the compiler it's a custom element so Vue doesn't try to resolve it as a Vue component:
export default {
plugins: [vue({ template: { compilerOptions: { isCustomElement: (tag) => tag.startsWith('bpdm-') } } })],
};Web Components
Framework-agnostic bpdm/ui elements (@bpdm/wc), standards-based custom elements that run in any stack, from React and Angular to PHP, Rails, Elixir/Phoenix LiveView, Django, WordPress and plain HTML.
One design system across React and Angular
A practical guide to sharing one design system across React and Angular, and any framework that reads CSS variables. The token-driven approach behind the bpdm/ui component libraries.