The Button That Ate Your Sprint

You've heard it a thousand times: "Use native HTML elements." But why? Madcampos' article "If you want to create a button from scratch, you must first create the universe" answers that question with a brutal, line-by-line walkthrough. The result: a custom button needs at least 15 distinct behaviors to match the native ``.

The Requirements List

The article references MDN, ARIA Authoring Practices Guide, and WCAG 2.2 to compile this non-exhaustive list:

  • Role of button (SC 4.1.2)
  • Accessible label (SC 4.1.2, SC 1.3.1)
  • Focusable (SC 2.4.3, SC 2.4.7)
  • Mouse click activation
  • Touch activation
  • Stylus/pointer activation
  • Space and Enter key activation (SC 2.1.1)
  • Type attribute (submit, reset, button)
  • Name/value and form attributes
  • Form validation participation (SC 3.3.1)
  • Disabled state (SC 4.1.2)
  • Support for newer APIs: Popover API, Invoker Commands API, Interest Invokers API

Step 1: The Empty Nucleus

Start with a custom element. It behaves like a `` by default, so you add role="button":

A button from scratch

Already you're behind native `` which has the role built-in.

Step 2: Accessible Label

Icon buttons break accessibility. An emoji alone fails WCAG SC 2.4.6 and SC 4.1.2. You need aria-label and hide the emoji:


  💩

Step 3: Focusability

Add tabindex="0" to make the element keyboard-focusable. Positive values are discouraged as they mess up tab order.

Step 4: Pointer Events

You need mouseup, touchend, and pointerup events. Browsers map some of these to click, but for completeness, implement all three:

Step 5: Keyboard Events

Space and Enter must trigger the action. This requires both keydown (for Enter) and keyup (for Space) listeners, with careful checks to avoid double-firing:

handleEvent(evt) {
  switch (evt.type) {
    case 'keyup':
      this.#handleSpaceActivation(evt);
      break;
    case 'keydown':
      this.#handleEnterActivation(evt);
      break;
  }
}

#handleSpaceActivation(evt) {
  if (evt.key !== ' ') return;
  if (document.activeElement !== this) return;
  this.#doButtonAction(evt);
}

#handleEnterActivation(evt) {
  if (evt.key !== 'Enter') return;
  this.#doButtonAction(evt);
}

Step 6: The Disabled State

Native `` handles disabled out of the box. For a custom element, you must:

  • Add disabled to observedAttributes
  • Sync ariaDisabled property
  • Add a custom state for CSS styling
static observedAttributes = ['disabled'];

set disabled(newValue) {
  this.toggleAttribute('disabled', newValue);
  this.#internals.ariaDisabled = newValue ? 'true' : 'false';
  if (newValue) {
    this.#internals.states.add('--disabled');
  } else {
    this.#internals.states.delete('--disabled');
  }
}

get disabled() {
  return this.hasAttribute('disabled');
}

attributeChangedCallback(name, oldValue, newValue) {
  if (oldValue === newValue) return;
  switch (name) {
    case 'disabled':
      this.disabled = newValue !== null;
      break;
  }
}

CSS: sagan-button:state(--disabled) { ... }

Step 7: Form Participation

A button can submit or reset a form. That means implementing type, form, formaction, formenctype, formmethod, formnovalidate, formtarget, name, and value attributes. Each requires its own getter/setter and attribute change handler. The article notes this is "a lot of attributes" – understatement.

Step 8: Modern APIs

The native `` already supports the Popover API (popovertarget), Invoker Commands, and Interest Invokers. Your custom element must implement each from scratch.

The Verdict

Madcampos doesn't finish the implementation – the article stops mid-sentence at the form attributes section. That's the point: it's so much work that even the author gave up listing everything. The article is a cautionary tale: don't rebuild what the platform gives you for free.

Why This Matters

Every time you reach for a `` with an ARIA role, you're signing up for months of maintenance. Native elements are tested across browsers, assistive technologies, and edge cases. Your custom button will never be as robust.

Next Steps

Audit your codebase for custom interactive elements. Replace with. For complex widgets, use established patterns from the ARIA Authoring Practices Guide. Your users – and your future self – will thank you.