Analysis

When a custom control is the right call

Native elements are the default, not a rule. The honest cases for replacing one:

SituationWhy native losesWhat you are accepting
Design requires styling the browser refuses::backdrop, <select> internals, validation bubblespermanent behaviour maintenance
Behaviour genuinely differsmulti-select combobox, virtualised listbox, rich textyou now own a WAI-ARIA pattern end to end
Cross-browser inconsistency is unacceptabledate/time inputs vary widely between enginesyou own date semantics, i18n and keyboard
The element does not existtree grid, split button, complex data gridthe full APG pattern, tested with real AT

The decision rule: you are not choosing markup, you are choosing an ownership boundary. The question is not "can we build this?" but "will this still be correct in three years, after four team changes, a framework upgrade, and a new browser focus behaviour?"

Measured cost of that ownership for the simplest possible case — a modal — is 5.4× the code and still one behaviour short. A combobox is an order of magnitude beyond that.

The styling boundary is the real trade

Almost every "we can't use the native element" reduces to styling. That is a legitimate constraint, and the platform has been closing the gap:

WantModern option
styled modal backdropdialog::backdrop
light-dismiss overlays without JS statePopover API (popover, popovertarget)
styled <select>appearance: base-select (emerging; check support before relying)
styled checkbox/radioaccent-color, or appearance:none + custom visuals on the real input
positioned anchored UICSS Anchor Positioning
exposed shadow internals::part()

The pattern worth internalising: keep the native element and restyle it, rather than replacing it with a div. appearance: none on a real <input type="checkbox"> gives complete visual control while retaining focus, keyboard, form participation and accessibility — which is the entire reason the element existed.

Native validation as source of truth

The architecture that survives contact with designers and localisation:

<input id="email" name="email" type="email" required
       aria-describedby="email-error">
<p id="email-error" role="alert" hidden></p>
// Constraints live in HTML. JS only presents them.
const show = (input, msgEl, messages) => {
  const v = input.validity;
  if (v.valid) { msgEl.hidden = true; return true; }
  const key = Object.keys(messages).find(k => v[k]) ?? 'default';
  msgEl.textContent = messages[key];       // YOUR locale, not the browser's
  msgEl.hidden = false;
  return false;
};
form.addEventListener('submit', (e) => { if (!form.checkValidity()) e.preventDefault(); /* … */ });
form.noValidate = true;   // suppress the bubble, keep the constraints

Three properties this has and a validation library does not: constraints are visible in the markup (so they are testable, server-mirrorable and greppable), the browser enforces them before your JS loads, and ValidityState gives you precise reason codes rather than boolean failure.

form.noValidate = true set from JavaScript is the key detail — the constraints still evaluate and checkValidity() still works; only the native bubble is suppressed. If JS fails to load, the attribute is absent and native validation protects the form.

Progressive enhancement as a failure-mode analysis

Not an ideology. The question is what the user gets in each degraded state:

FailureNative-first formJS-validation form
Bundle fails to loadworks, validates, submitsinert inputs, no submit
JS throws during hydrationworksbroken, often silently
Slow network, JS pendingusable immediatelyvisible but non-functional
Extension/CSP blocks a scriptworksbroken

This is the concrete link into fe-20 (unhappy path). The list above is not hypothetical — "visible but non-functional" is the single most common production complaint about SPAs, and it is decided by choices made in this module.

What breaks at scale

  • Design systems calcify the choice. If a system ships <Button> as a styled div, every consuming app inherits the liability, and fixing it later is a breaking change across dozens of teams. This is the highest-leverage decision a design system makes and it is usually made in week one, by one person, without measurement (fe-38).
  • ARIA drifts out of sync. aria-expanded on a div is state you must remember to update in every code path. A <details>/<summary> cannot drift because the browser owns the state. Every ARIA attribute is a manual invariant, and manual invariants decay.
  • Framework abstractions hide element choice. <Box as="button"> and styled-component factories make the underlying element a prop, so nobody reviews it. Auditing "what element does this actually render" becomes archaeology.
  • Shadow DOM changes focus and accessibility traversal. Custom elements with shadow roots need delegatesFocus, careful ::part exposure, and explicit label association. Encapsulation is not free at the accessibility boundary.
  • Automated checks pass on div soup. Contrast and alt-text checkers do not flag "this should have been a button". The div-soup variant would score well on many automated audits while exposing zero controls — which is the limit of automation the specification asks us to teach (fe-24).