Analysis
When a custom control is the right call
Native elements are the default, not a rule. The honest cases for replacing one:
| Situation | Why native loses | What you are accepting |
|---|---|---|
| Design requires styling the browser refuses | ::backdrop, <select> internals, validation bubbles | permanent behaviour maintenance |
| Behaviour genuinely differs | multi-select combobox, virtualised listbox, rich text | you now own a WAI-ARIA pattern end to end |
| Cross-browser inconsistency is unacceptable | date/time inputs vary widely between engines | you own date semantics, i18n and keyboard |
| The element does not exist | tree grid, split button, complex data grid | the 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:
| Want | Modern option |
|---|---|
| styled modal backdrop | dialog::backdrop |
| light-dismiss overlays without JS state | Popover API (popover, popovertarget) |
styled <select> | appearance: base-select (emerging; check support before relying) |
| styled checkbox/radio | accent-color, or appearance:none + custom visuals on the real input |
| positioned anchored UI | CSS 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:
| Failure | Native-first form | JS-validation form |
|---|---|---|
| Bundle fails to load | works, validates, submits | inert inputs, no submit |
| JS throws during hydration | works | broken, often silently |
| Slow network, JS pending | usable immediately | visible but non-functional |
| Extension/CSP blocks a script | works | broken |
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 styleddiv, 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-expandedon 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::partexposure, 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).