Accessible, themeable button web component built on Custom Elements V1.
- Stateless at render time
- Framework-free
- Themeable via semantic CSS custom properties
- Accessible by default through a native internal
<button>
<x-button>Save</x-button>x-button provides a reusable action control for user-triggered operations such as submit, reset, confirm, cancel, toggle, and destructive actions.
It supports:
- disabled state
- loading state
- pressed/toggled state
- semantic button types
- visual variants
- multiple sizes
- optional leading and trailing icons
- optional spinner content
- accessible keyboard and focus behavior
import { init } from '@vanelsas/baredom/x-button';
init();Registration is idempotent — calling init() on an already-registered element is a no-op.
<x-button>Save</x-button><x-button type="submit">Create account</x-button><x-button disabled>Unavailable</x-button><x-button loading label="Saving">
Saving
<span slot="spinner" aria-hidden="true"></span>
</x-button><x-button pressed>Bold</x-button><x-button label="Close">
<svg slot="icon-start" aria-hidden="true" viewBox="0 0 24 24"></svg>
</x-button><x-button>
<svg slot="icon-start" aria-hidden="true" viewBox="0 0 24 24"></svg>
Next
<svg slot="icon-end" aria-hidden="true" viewBox="0 0 24 24"></svg>
</x-button>x-button
Boolean attribute.
When present, the button is disabled and cannot be activated.
<x-button disabled>Disabled</x-button>Behavior:
- blocks pointer interaction
- blocks keyboard activation
- suppresses press lifecycle events
- suppresses hover lifecycle events
- disables the internal native
<button>
Boolean attribute.
When present, the button enters a busy/loading state and prevents duplicate activation.
<x-button loading label="Saving">
Save
<span slot="spinner" aria-hidden="true"></span>
</x-button>Behavior:
- disables the internal native
<button> - sets
aria-busy="true" - prevents duplicate activation
- suppresses interaction lifecycle events
- allows loading visuals to be shown
Boolean attribute.
When present, the button is treated as pressed/toggled.
<x-button pressed>Bold</x-button>Behavior:
- sets
aria-pressed="true" - enables pressed styling
When absent:
aria-pressedis removed — the button is not treated as a toggle control
Enum attribute.
Allowed values:
buttonsubmitreset
Default:
button
Examples:
<x-button type="button">Open</x-button>
<x-button type="submit">Submit</x-button>
<x-button type="reset">Reset</x-button>Invalid values normalize to button.
Enum attribute.
Allowed values:
primarysecondarytertiaryghostdanger
Default:
primary
Examples:
<x-button variant="primary">Save</x-button>
<x-button variant="secondary">Cancel</x-button>
<x-button variant="tertiary">Learn more</x-button>
<x-button variant="ghost">More</x-button>
<x-button variant="danger">Delete</x-button>Invalid values normalize to primary.
Enum attribute.
Allowed values:
smmdlg
Default:
md
Examples:
<x-button size="sm">Small</x-button>
<x-button size="md">Medium</x-button>
<x-button size="lg">Large</x-button>Invalid values normalize to md.
String attribute.
Used as the accessible name fallback when the default slot does not contain meaningful text.
<x-button label="Close">
<svg slot="icon-start" aria-hidden="true"></svg>
</x-button>Behavior:
- if the default slot has meaningful text,
labelis not needed for naming - if the default slot does not provide meaningful text,
labelis used asaria-label - if neither visible text nor
labelis present, the component is accessible-name invalid
These properties reflect boolean attributes.
Type:
boolean
Reflects:
disabled
Example:
const el = document.querySelector("x-button");
el.disabled = true;Type:
boolean
Reflects:
loading
Example:
const el = document.querySelector("x-button");
el.loading = true;Type:
boolean
Reflects:
pressed
Example:
const el = document.querySelector("x-button");
el.pressed = false;Used for the button label/content.
<x-button>Save changes</x-button>Optional leading icon slot.
<x-button>
<svg slot="icon-start" aria-hidden="true"></svg>
Download
</x-button>Optional trailing icon slot.
<x-button>
Next
<svg slot="icon-end" aria-hidden="true"></svg>
</x-button>Optional loading indicator slot.
<x-button loading label="Saving">
Save
<span slot="spinner" aria-hidden="true"></span>
</x-button>By default, spinner content is treated as decorative and should usually be marked aria-hidden="true" unless explicitly intended to be announced.
All custom events:
- bubble
- are composed
- are dispatched from the host
x-buttonelement
Emitted when activation succeeds.
Detail shape:
{ source: "pointer" | "keyboard" | "programmatic" }Example:
button.addEventListener("press", (event) => {
console.log(event.detail.source);
});Notes:
- not emitted when
disabled - not emitted when
loading
Emitted when a valid press interaction begins.
Detail shape:
{ source: "pointer" | "keyboard" }Example:
button.addEventListener("press-start", (event) => {
console.log(event.detail.source);
});Emitted when a valid press interaction ends or is canceled.
Detail shape:
{ source: "pointer" | "keyboard" }Example:
button.addEventListener("press-end", (event) => {
console.log(event.detail.source);
});Emitted when an interactive pointer enters the button.
Detail shape:
{}Example:
button.addEventListener("hover-start", () => {
console.log("hover start");
});Emitted when an interactive pointer leaves the button.
Detail shape:
{}Example:
button.addEventListener("hover-end", () => {
console.log("hover end");
});Emitted when the internal native button enters visible keyboard focus state.
Detail shape:
{}Example:
button.addEventListener("focus-visible", () => {
console.log("keyboard focus visible");
});x-button uses a native internal <button> inside Shadow DOM.
This gives it:
- native button semantics
- native keyboard behavior
- predictable disabled behavior
- proper focus participation
Form submission and reset are handled explicitly via ElementInternals — see Form Behavior.
When disabled is present:
- the internal button is disabled
- the control cannot be focused or activated through normal interaction
- press and hover lifecycle events are suppressed
When loading is present:
- the internal button is disabled
aria-busy="true"is applied- duplicate activation is blocked
When pressed is present:
aria-pressed="true"is set
When pressed is absent:
aria-pressedis removed (the button is treated as a regular action button, not a toggle)
Preferred name sources:
- meaningful text in the default slot
labelattribute as fallback
Good:
<x-button>Save</x-button>Good:
<x-button label="Close">
<svg slot="icon-start" aria-hidden="true"></svg>
</x-button>Invalid authoring:
<x-button></x-button>Invalid authoring with only decorative content:
<x-button>
<svg slot="icon-start" aria-hidden="true"></svg>
</x-button>Unless label is provided.
Because the internal control is a native button, it supports:
EnterSpace- focus via keyboard navigation
- submit/reset behavior where applicable
The component includes a visible focus style for keyboard-visible focus.
The spinner region is decorative by default. Authors should avoid allowing spinner visuals to replace the accessible name.
Recommended:
<x-button loading label="Saving">
Saving
<span slot="spinner" aria-hidden="true"></span>
</x-button>The component is styled via semantic CSS custom properties defined on the host.
You can override these variables from outside the component.
--x-button-radius--x-button-gap--x-button-padding-inline--x-button-height-sm--x-button-height-md--x-button-height-lg--x-button-font-size-sm--x-button-font-size-md--x-button-font-size-lg--x-button-font-weight--x-button-icon-size-sm--x-button-icon-size-md--x-button-icon-size-lg--x-button-spinner-size--x-button-spinner-stroke
--x-button-bg--x-button-bg-hover--x-button-bg-active--x-button-bg-disabled--x-button-fg--x-button-fg-disabled--x-button-border--x-button-border-hover--x-button-border-active--x-button-focus-ring
--x-button-secondary-bg--x-button-secondary-bg-hover--x-button-secondary-bg-active--x-button-secondary-fg--x-button-secondary-border
--x-button-tertiary-bg--x-button-tertiary-bg-hover--x-button-tertiary-bg-active--x-button-tertiary-fg
--x-button-ghost-bg--x-button-ghost-bg-hover--x-button-ghost-bg-active--x-button-ghost-fg
--x-button-danger-bg--x-button-danger-bg-hover--x-button-danger-bg-active--x-button-danger-fg
--x-button-shadow--x-button-shadow-hover--x-button-shadow-active
--x-button-transition-duration--x-button-transition-easing
The following shadow parts are exposed:
buttoninnerlabelicon-starticon-endspinnerspinner-slotspinner-fallback
Example:
x-button::part(button) {
font-weight: 600;
}
x-button::part(label) {
letter-spacing: 0.01em;
}x-button.brand {
--x-button-bg: #2563eb;
--x-button-bg-hover: #1d4ed8;
--x-button-bg-active: #1e40af;
--x-button-focus-ring: #93c5fd;
}<x-button class="brand">Continue</x-button>The component provides default theme-aware values using prefers-color-scheme.
Host-level CSS variable overrides take precedence over defaults.
The component uses minimal CSS-based motion for:
- hover state
- press state
- focus indication
- loading affordance
It respects:
@media (prefers-reduced-motion: reduce)In reduced motion environments, transitions are removed.
x-button sets static formAssociated = true so the browser tracks form association. The internal shadow DOM <button> cannot submit or reset a light DOM form on its own (shadow DOM is a separate tree), so form participation is implemented explicitly in the click handler.
The host element is searched for a form owner using the standard HTML algorithm:
- If the
formattribute is present, the form with thatidis used. - Otherwise, the nearest ancestor
<form>is used.
type="submit"→ callsform.requestSubmit(), which runs constraint validation and fires thesubmiteventtype="reset"→ callsform.reset()type="button"→ no form interaction
No form submission.
Submits the nearest ancestor form, running constraint validation.
Resets the nearest ancestor form.
Example:
<form>
<input type="text" required />
<x-button type="submit">Submit</x-button>
<x-button type="reset" variant="secondary">Reset</x-button>
</form>Invalid or missing values normalize to:
button
Invalid or missing values normalize to:
primary
Invalid or missing values normalize to:
md
Examples:
<x-button type="oops">Save</x-button>
<x-button variant="unknown">Save</x-button>
<x-button size="xl">Save</x-button>These behave as if written:
<x-button type="button" variant="primary" size="md">Save</x-button><x-button id="save-btn" variant="primary">Save</x-button>
<script>
const btn = document.getElementById("save-btn");
btn.addEventListener("press", (event) => {
console.log("press", event.detail);
});
btn.addEventListener("press-start", (event) => {
console.log("press-start", event.detail);
});
btn.addEventListener("press-end", (event) => {
console.log("press-end", event.detail);
});
btn.addEventListener("hover-start", () => {
console.log("hover-start");
});
btn.addEventListener("hover-end", () => {
console.log("hover-end");
});
btn.addEventListener("focus-visible", () => {
console.log("focus-visible");
});
</script>Best:
<x-button>Save</x-button>Best:
<x-button label="Close">
<svg slot="icon-start" aria-hidden="true"></svg>
</x-button>Mark decorative icons as hidden
Recommended:
<svg slot="icon-start" aria-hidden="true"></svg>Recommended:
<span slot="spinner" aria-hidden="true"></span>Avoid:
<x-button></x-button>x-button does not include:
- framework adapters
- internal async state management
- virtual DOM
- reactive runtime
- toggle-group coordination
- icon library integration
- imperative animation system
x-button is a platform-native button component that provides:
- native button semantics
- accessible interaction
- boolean reflection for
disabled,loading, andpressed - normalized enums for
type,variant, andsize - slot-based icon and spinner composition
- host-variable theming
- reduced-motion support
- stable exported metadata through
public-api
It is intended to be a simple, robust foundation for action controls in any web application.