Custom elements have been shippable for years, and the difference between a component library people adopt and one they route around comes down to a handful of implementation choices. Here is how to tell which one you are looking at within about two minutes of reading the source.
Shadow root opened with mode: 'closed' — it blocks your own test tooling and every debugging session, and it stops nobody determined. Use open unless you have a specific, written reason.
No styling API — if the only way to restyle a button is !important from outside, the shadow boundary has become a wall. Expose ::part() hooks and custom properties, or consumers will fork the component.
Listeners and observers added in connectedCallback with no matching disconnectedCallback — every mount leaks a ResizeObserver or a document-level listener. This is the single most common memory leak in component libraries.
Reaching outside itself — document.querySelector from inside a component couples it to a page structure it does not own. Take references via slots, attributes or properties instead.
innerHTML built from attribute values — an attribute is untrusted input the moment anything renders user data into markup. Set textContent, or sanitise deliberately.
No server rendering story — a component that renders nothing until JavaScript executes turns every page into a blank box for the first second and hurts LCP directly.
Attributes and properties stay in sync — static observedAttributes lists the reactive ones, attributeChangedCallback updates state, and property setters reflect back to attributes where it makes sense. Frameworks pass strings via attributes and objects via properties; a component that only handles one breaks in half of them.
Form-associated via ElementInternals — static formAssociated = true plus internals.setFormValue() and setValidity() means the component submits with the form, participates in :invalid, and works with native validation instead of reimplementing it.
Declarative Shadow DOM support — shipping <template shadowrootmode="open"> in the server response means the component renders before any script runs and hydrates in place.
Events are real events — new CustomEvent('change', {bubbles: true, composed: true, detail}) crosses the shadow boundary and behaves like the platform. Callback props on a custom element do not.
Slots for content, not string attributes — named slots let consumers pass real markup, keep it in the light DOM where their CSS reaches it, and preserve accessibility semantics.
Focus handled explicitly — attachShadow({mode:'open', delegatesFocus:true}) and a sensible tabindex so keyboard users can reach the interactive part inside.
Load the component with JavaScript disabled: do you see anything? Mount and unmount it a hundred times and take a heap snapshot: does retained size grow? Put it in a native <form> and submit: does its value appear? Tab into it: does focus land somewhere sensible? Four checks, and they separate a component you can build on from one you will end up replacing.
Free tools, guides, and resources across the SPUNK13 network.
Visit spunk.bet400+ Free Tools