Chapter 46: Building kit-button
A button should be a button.
That’s the most important design requirement for kit-button. The component can add styling variants, slots, loading states, and metadata participation — but it should preserve native button behavior end to end. Keyboard activation, focus, disabled state, form-submission behavior, accessible name calculation, screen-reader announcements, mobile touch targets, hover and active states — all of these should come from the platform wherever possible.
This chapter builds kit-button. The component is the simplest case in the Kit component library, which makes it the right starting point for the patterns the rest of Part V uses. Native element underneath. Lit for authoring. Metadata protocol for participation. Form association. Accessible by default.
The Public API
Section titled “The Public API”A working kit-button declaration:
<kit-button variant="primary" size="md" meta-event="profile.saved"> <kit-icon slot="icon-start" name="check"></kit-icon> Save profile</kit-button>Properties the component exposes:
variant— visual variant (primary,secondary,ghost,danger, etc.; the design system decides).size— visual size (sm,md,lg).disabled— disabled state, reflected to the host element.loading— loading state, with accessible announcement.type—button,submit, orreset(matches the native<button>type).meta-event/meta-command/meta-intent— metadata protocol attributes (Chapter 22).nameandvalue— when form-associated, these participate in form submission.
Slots:
- default — the button’s label text.
- icon-start — an icon before the label.
- icon-end — an icon after the label.
Events:
- The component dispatches a normal
clickevent when activated, which bubbles up the regular DOM. Because the metadata protocol uses delegated listeners (Chapter 42), the click is observed automatically by the surrounding boundary; no special wiring is required.
Implementation
Section titled “Implementation”A full Lit implementation:
import { LitElement, html, css, nothing } from 'lit'import { property } from 'lit/decorators.js'
class KitButton extends LitElement { // Form-associated custom element setup static formAssociated = true
@property({ type: String, reflect: true }) variant: 'primary' | 'secondary' | 'ghost' | 'danger' = 'secondary' @property({ type: String, reflect: true }) size: 'sm' | 'md' | 'lg' = 'md' @property({ type: Boolean, reflect: true }) disabled = false @property({ type: Boolean, reflect: true }) loading = false @property({ type: String }) type: 'button' | 'submit' | 'reset' = 'button' @property({ type: String }) name = '' @property({ type: String }) value = ''
private internals: ElementInternals
constructor() { super() this.internals = this.attachInternals() }
static styles = css` :host { display: inline-block; --kit-button-padding-y: 0.5em; --kit-button-padding-x: 1em; --kit-button-radius: 0.25rem; --kit-button-border-color: currentColor; --kit-button-bg: transparent; --kit-button-color: inherit; }
:host([size="sm"]) { --kit-button-padding-y: 0.25em; --kit-button-padding-x: 0.75em; font-size: 0.875rem; }
:host([size="lg"]) { --kit-button-padding-y: 0.75em; --kit-button-padding-x: 1.5em; font-size: 1.125rem; }
:host([variant="primary"]) { --kit-button-bg: var(--kit-color-accent, #4a90e2); --kit-button-color: white; --kit-button-border-color: var(--kit-color-accent, #4a90e2); }
:host([variant="danger"]) { --kit-button-bg: var(--kit-color-danger, #d04444); --kit-button-color: white; --kit-button-border-color: var(--kit-color-danger, #d04444); }
:host([variant="ghost"]) { --kit-button-bg: transparent; --kit-button-border-color: transparent; }
button { display: inline-flex; align-items: center; gap: 0.5em; padding: var(--kit-button-padding-y) var(--kit-button-padding-x); border-radius: var(--kit-button-radius); border: 1px solid var(--kit-button-border-color); background: var(--kit-button-bg); color: var(--kit-button-color); font: inherit; cursor: pointer; transition: opacity 150ms ease; }
button:focus-visible { outline: 2px solid var(--kit-color-focus, currentColor); outline-offset: 2px; }
button:disabled { opacity: 0.5; cursor: not-allowed; }
@media (prefers-reduced-motion: reduce) { button { transition: none; } }
.loading-indicator { display: inline-block; width: 0.875em; height: 0.875em; border: 2px solid currentColor; border-right-color: transparent; border-radius: 50%; animation: spin 0.8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } } `
render() { return html` <button type=${this.type} ?disabled=${this.disabled || this.loading} aria-busy=${this.loading ? 'true' : nothing} @click=${this.handleClick} > ${this.loading ? html`<span class="loading-indicator" aria-hidden="true"></span>` : html`<slot name="icon-start"></slot>`} <slot></slot> <slot name="icon-end"></slot> </button> ` }
private handleClick(event: MouseEvent) { if (this.disabled || this.loading) { event.stopPropagation() event.preventDefault() return }
// If we're a submit button inside a form, trigger the form submission // (since the internal button is inside the shadow DOM, the native // form-submit chain doesn't reach the form by default). if (this.type === 'submit' && this.internals.form) { this.internals.form.requestSubmit() } }}
customElements.define('kit-button', KitButton)The implementation is around 100 lines. Most of it is styling and the CSS-custom-property setup. The actual behavior is a few lines: the click handler delegates to the form on submit-type, the disabled state is handled by the native button’s disabled attribute, the loading state adds aria-busy and replaces the icon slot with a spinner.
Why a Native Button Inside
Section titled “Why a Native Button Inside”The component renders a real <button> inside its shadow DOM rather than implementing button semantics from scratch on the :host element. The reason is the accessibility and keyboard story from Chapter 20.
A real <button> element gets the platform’s full button behavior — keyboard activation (Enter and Space both trigger click), focus ring, focus-visible state, disabled handling, accessible role, accessible name calculation, screen-reader announcement as button. None of this is something the component would have to reimplement; the platform provides it free.
If the component used :host with role="button" and manual keyboard handling, it would have to recreate every one of those behaviors. Some recreations would be incomplete; some would have subtle bugs; the maintenance burden would be real. The native-button-inside approach is the simplest path to the component is actually a button.
The shadow boundary means the inner button isn’t directly accessible from outside the component. Application code can’t reach in and call innerButton.click() from document.querySelector. This is fine — the application interacts with the component through the component’s API (its properties, its events, its slots), and the component handles the internal button itself.
Form Association
Section titled “Form Association”The static formAssociated = true declaration (Chapter 24) lets kit-button participate in forms.
When a <kit-button type="submit"> is inside a <form>, the component’s handleClick calls this.internals.form.requestSubmit(), which triggers the form’s submission with full native semantics — submit event fires, constraint validation runs, FormData is constructed, the metadata boundary observes the form’s data-meta-event, the runtime fires the application-level event.
The form association also means the button shows up in the form’s elements collection, can be styled with :disabled, and participates in the form’s tab order. The platform treats it as a real form control.
A subtle detail: the native button inside the shadow DOM isn’t directly the form’s submit button (the form sees the <kit-button>, not the inner <button>). The requestSubmit() call is what bridges this. Without it, the inner button’s click would not propagate to the form’s submit event automatically.
Variants and Sizing
Section titled “Variants and Sizing”The component supports variant and size attributes for visual variation. Both are reflected to the host element (using reflect: true in the decorator), which means they appear as HTML attributes that CSS can target.
The styling uses CSS custom properties (--kit-button-bg, --kit-button-color, etc.) that the variant selectors override. This pattern (Chapter 27’s CSS-as-runtime, applied to component styling) means a parent boundary can override the custom properties to theme the buttons inside it, without the component having to know about themes.
/* Application-wide theme override */[data-theme="dark"] kit-button { --kit-button-bg: #222; --kit-button-color: #eee;}The component’s styles are scoped (Shadow DOM), but the custom properties cross the shadow boundary (which is the platform’s design). The result is component styling that’s encapsulated but themeable through the cascade.
Disabled and Loading States
Section titled “Disabled and Loading States”The two states have different semantics and the component treats them differently.
Disabled is the platform’s mechanism for this button is not interactive right now. The native button receives disabled. The component reflects disabled to the host. CSS can target the host with [disabled]. The button doesn’t fire clicks. Keyboard navigation skips it. Screen readers announce it as disabled. The platform handles all of this.
Loading is the application’s signal for this button started an action that’s still in progress. The component uses aria-busy="true" to tell assistive technology that work is happening. The visual presentation includes a spinner. The button is also disabled while loading, so it can’t be clicked again before the work completes. The component preserves the button’s accessible name during loading (the label text stays in the default slot).
The interaction between the two states is intentional. A loading button is also disabled. A disabled button isn’t necessarily loading. The component’s handleClick ignores clicks when either is true, providing defense in depth in case some browser fires the click anyway.
Metadata Participation
Section titled “Metadata Participation”kit-button participates in the metadata protocol from Chapter 22 without doing any special work itself. The button declares meta-event (or meta-command) as a plain attribute, and the metadata boundary’s delegated listener handles the rest:
<kit-button meta-event="profile.saved" meta-intent="primary-action"> Save</kit-button>When the button is clicked, the click bubbles up the regular DOM (out of the component’s shadow root, through the application’s tree, up to the shell root). The metadata boundary’s listener finds the button via closest('[meta-event]') and dispatches the runtime event.
The component doesn’t need to know about the metadata boundary. The architecture handles it. This is the decoration-versus-replacement principle in action — the component is a decoration on the platform’s button primitive; the architecture is a decoration on the DOM’s event system; the two compose without either being aware of the other’s specific implementation.
Accessibility Checklist
Section titled “Accessibility Checklist”A kit-button component should pass each of these tests:
- Keyboard activation works. Tab to it. Enter and Space both activate it.
- Focus ring appears on keyboard focus. The
:focus-visiblepseudo-class triggers the outline; mouse-focus does not. - Disabled state is keyboard-skipped. The native disabled attribute removes it from the tab order.
- Screen reader announces it correctly. It should be announced as button with the accessible name from its slot content.
- Loading state is announced.
aria-busy="true"tells assistive technology the button is doing something. - Form submission works. A
type="submit"button inside a form submits the form correctly. - Accessible name calculation works. The default slot’s text content becomes the button’s accessible name. If the slot is empty, an
aria-labeloraria-labelledbyattribute on the host should be respected. - Focus order is correct. The component appears in the natural DOM order; no JavaScript-altered tab indices.
- Hover, focus, active states all work. The browser’s native states apply correctly to the inner button.
- Reduced motion is honored. The transition on the button disables when
prefers-reduced-motionis set.
The Chapter 29 accessibility-is-the-platform-contract argument applies directly. By using a real native button underneath, the component inherits the platform’s button semantics — all of the above happens because the platform implements it, not because the component implements it.
Composing kit-button
Section titled “Composing kit-button”A few common patterns the rest of the Kit library uses.
Inside a form:
<form data-meta-event="profile.save_requested"> <kit-input name="displayName" required></kit-input> <kit-button type="submit" variant="primary" meta-intent="primary-action"> Save </kit-button></form>With icons:
<kit-button> <kit-icon slot="icon-start" name="plus"></kit-icon> Add item</kit-button>As a command trigger:
<kit-button meta-command="dialog.open" meta-prop-target="help-dialog" variant="ghost"> Help</kit-button>With loading state:
const button = document.querySelector('kit-button')!
async function save() { button.loading = true try { await api.save() } finally { button.loading = false }}Each pattern leans on the same underlying capabilities. The component’s API is consistent. The integration with the architecture is automatic.
Bridge to kit-dialog
Section titled “Bridge to kit-dialog”The next chapter builds kit-dialog. The pattern is similar — native <dialog> underneath, Lit for authoring, accessibility preserved, metadata-protocol participation. The dialog adds some specific concerns (focus trap, focus restoration, modal vs. non-modal behavior, the backdrop) that kit-button doesn’t have. The chapter walks through each.
Exercise: Build kit-button
Section titled “Exercise: Build kit-button”Implement kit-button following the pattern in this chapter. Build:
- The component with
variant,size,disabled,loading,type,name, andvalueproperties. - The internal native
<button>rendered with the correct attributes. - Form association via
static formAssociated = trueandattachInternals(). - CSS custom properties for theming.
- Slots for default content,
icon-start, andicon-end.
Then test each behavior:
- Keyboard activation: Tab to the button, press Enter, verify it fires a click.
- Disabled behavior: Set
disabled, verify it’s keyboard-skipped and unclickable. - Loading state: Set
loading, verifyaria-busyappears, the spinner shows, and clicks are ignored. - Form submission: Put the button inside a form with
type="submit", verify it submits the form. - Variants and sizing: Try different
variantandsizevalues, verify the styling adapts. - Theme override: Wrap in a
[data-theme="dark"]boundary with overridden custom properties, verify the colors change. - Screen reader: Use VoiceOver or NVDA. Verify the announcement is correct.
Then integrate with the architecture from Part IV:
- Add
meta-event="test.button_clicked"to a button. Verify the runtime event fires when the button is clicked. - Wrap the button in a
<kit-boundary>with surface and feature attributes. Verify the event includes the boundary context. - Subscribe to the event from a module. Verify the module’s handler runs.
Reflect on:
- How much of the button’s behavior came from the platform (the native
<button>inside)? - How much from Lit (reactive properties, templating, scoped styles)?
- How much from the component’s own code (loading state, form association, click delegation)?
- If you wanted to replace Lit with another authoring layer (Stencil, custom-element-only, FAST), how much would change? (The reactive-properties syntax and templating; the underlying behavior and accessibility would carry forward.)
The component is small. The leverage shows up in the composition — every form, every dialog, every interactive surface in the application uses kit-button, and every one gets the platform’s behavior automatically.