Web Components are most valuable at ownership boundaries: a design-system control used by several frameworks, an embedded commerce widget, or a durable platform primitive that should outlive one application stack. A custom element supplies lifecycle and an element-level API. Shadow DOM supplies a scoped internal tree. Templates and slots supply composition. None of them automatically produces a good component contract.
The central design rule is to treat the element like a small browser-native service. Its attributes, properties, methods, events, slots, parts, form behavior, and accessibility semantics are public interfaces. Internal markup is not. If those boundaries are explicit, a component can integrate cleanly with server rendering and multiple frameworks. If they are vague, encapsulation merely makes bugs harder to inspect.
This article builds that contract around a form-associated checkbox and then follows it through upgrade, styling, composition, SSR, and tests. The point is not the checkbox itself; it is the integration discipline that keeps a custom element predictable wherever it is hosted.
Design the Element Contract Before the Shadow Tree
Begin with the behavior visible to a consumer. Decide what belongs in HTML attributes, JavaScript properties, methods, events, and child content. Attributes are serialized strings and are ideal for declarative, low-complexity state such as disabled, checked, name, and a scalar value. Properties can carry typed or structured runtime data. Methods should represent commands that cannot be expressed as state. Events announce outcomes rather than accept commands.
Boolean attributes follow presence semantics: <terms-checkbox disabled="false"> is disabled because the attribute exists. Consumers remove the attribute to make the value false. A corresponding property can provide normal booleans while reflecting to the attribute. Documenting a different convention creates friction with HTML and framework tooling.
Choose one owner for every node. The host application owns the element and its light-DOM children. The component owns its shadow tree. A <slot> projects light content without transferring ownership, so the component should not rewrite slotted nodes merely because it can reach them. This rule prevents a framework’s renderer and the custom element from racing to mutate the same subtree.
An autonomous custom element such as <terms-checkbox> is usually a more portable boundary than a customized built-in such as <input is="terms-checkbox">. The latter depends on integration support that has historically varied. The cost of an autonomous control is that native semantics, form behavior, and keyboard behavior must be supplied deliberately rather than assumed.
A compact public contract for the example is:
| Surface | Meaning |
|---|---|
checked attribute/property |
Current selected state |
disabled attribute/property |
Prevents interaction and form contribution |
name and value |
Native form serialization contract |
| Default slot | Human-readable label |
input event |
State changed during user interaction |
change event |
User committed a new state |
--checkbox-accent |
Supported theme token |
::part(mark) |
Narrow styling hook for the visual mark |
Do not expose the internal span structure or require a consumer to query shadowRoot. If an integration needs a capability, promote it to a named public surface.
Make Lifecycle Work Idempotent and Reversible
A custom element may be constructed before it is connected, disconnected and reconnected, adopted into another document, or upgraded after markup already exists. Lifecycle callbacks are notifications, not a promise of one linear mount and unmount.
The constructor should establish private state, create or adopt the shadow root, and attach listeners whose targets already exist. It should not depend on light-DOM children being parsed, fetch remote data, or inspect layout. connectedCallback can synchronize document-dependent behavior. disconnectedCallback must release document listeners, observers, timers, and abortable work. Reconnection should not duplicate any of them.
Use an AbortController per connection for outside listeners:
class PopoverTrigger extends HTMLElement {
#connection?: AbortController;
connectedCallback(): void {
if (this.#connection) return;
this.#connection = new AbortController();
document.addEventListener('pointerdown', this.#onOutsidePointer, {
capture: true,
signal: this.#connection.signal,
});
}
disconnectedCallback(): void {
this.#connection?.abort();
this.#connection = undefined;
}
#onOutsidePointer = (event: PointerEvent): void => {
if (!event.composedPath().includes(this)) this.hidePopover();
};
}
The guard makes repeated connection harmless, and aborting removes every listener registered with the signal. Internal listeners attached once in the constructor can remain with the element for its lifetime. Observers should follow the same ownership rule: observe only while their results can be used, and disconnect when the host leaves the document.
attributeChangedCallback runs only for names listed in observedAttributes. Keep it a synchronization boundary, not an unrestricted render loop. Normalize incoming strings, compare old and new values, and update only affected state. Property setters that reflect attributes must avoid cycles by checking whether the serialized value already matches.
Registration is global per custom-element registry. Define once and use a stable tag prefix to avoid collisions:
if (!customElements.get('acme-terms-checkbox')) {
customElements.define('acme-terms-checkbox', TermsCheckbox);
}
Calling define twice throws. Silently accepting two incompatible implementations would be worse, so libraries should also ensure that only one version owns a tag name.
Use Shadow DOM as Encapsulation, Not Secrecy
A shadow root gives the component a separate node tree with style scoping, event retargeting, and slot composition. Selectors inside it do not match arbitrary light-DOM descendants, and ordinary page selectors do not reach its internals. That reduces accidental coupling, but it is not a security boundary. Page scripts can interact with the host, dispatch events, inspect an open root, and influence inherited or exposed styles.
Prefer mode: 'open' for application and design-system components unless a concrete invariant requires otherwise. A closed root mostly removes a standard inspection handle; it does not make secrets safe, and it complicates debugging, testing, accessibility investigation, and legitimate integration. Sensitive data never belongs in client-side markup regardless of root mode.
CSS inheritance still crosses the boundary. Inherited properties such as color, font-family, and direction can affect shadow descendants. CSS custom properties inherit too, which makes them useful as intentional theme inputs. Global element selectors do not cross, and IDs inside separate shadow trees do not collide for selector matching.
Events crossing a shadow boundary are retargeted so outside listeners commonly see the host as event.target. event.composedPath() reveals the propagation path appropriate to the listener and is the right tool for inside/outside checks. An event must be both bubbles: true and composed: true if it should bubble from an internal node through the host into application code:
this.dispatchEvent(new CustomEvent('quantity-change', {
bubbles: true,
composed: true,
detail: { value: this.value },
}));
Keep event details small, immutable by convention, and versionable. Do not expose internal elements in detail; that turns private markup into a public API. For native-like controls, use expected native events when their semantics match instead of inventing parallel names.
Compose with Slots and Expose Deliberate Styling Hooks
A slot is a location in the shadow tree where matching light-DOM children render. Named slots create explicit regions such as icon, summary, and actions; an unnamed slot accepts remaining children. The slotted node remains a child of the host in the light DOM, while its rendered position follows the slot.
Fallback content inside <slot> appears only when no assigned node supplies that slot. slotchange reports changes to the assigned-node set, not every mutation beneath an already assigned node. If descendant text changes matter, observe that owned light subtree separately and clean up the observer on disconnection.
Styling should have a narrow, layered API:
:hostdefines the component’s default outer display and inherited behavior.:host([disabled])and similar selectors represent public state.- Custom properties expose semantic values such as accent color or spacing.
::part(name)exposes a selected internal element for controlled consumer styling.::slotted(selector)styles only directly assigned slotted elements and has limited selector reach.
For the checkbox, an internal stylesheet might say:
:host {
--checkbox-accent: CanvasText;
display: inline-flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
}
:host([disabled]) {
cursor: not-allowed;
opacity: 0.6;
}
[part='mark'] {
inline-size: 1rem;
block-size: 1rem;
border: 2px solid currentColor;
}
:host([checked]) [part='mark'] {
background: var(--checkbox-accent);
}
@media (forced-colors: active) {
[part='mark'] { forced-color-adjust: auto; }
}
Expose semantic tokens rather than mirroring every declaration as a variable. Too many hooks freeze the internal implementation because consumers begin depending on combinations no one intended to support. A part name should survive a markup refactor or be versioned as a breaking API change.
Build a Form-Associated, Accessible Checkbox
Shadow DOM does not automatically make an internal input participate in the outer form as the custom host. ElementInternals lets an autonomous custom element contribute values, validation, form lifecycle, and accessibility semantics. The following simplified implementation provides one coherent control:
const template = document.createElement('template');
template.innerHTML = `
<style>
:host { display: inline-flex; align-items: center; gap: .5rem; cursor: pointer; }
:host([disabled]) { cursor: not-allowed; opacity: .6; }
[part="mark"] { inline-size: 1rem; block-size: 1rem; border: 2px solid; }
:host([checked]) [part="mark"] { background: var(--checkbox-accent, CanvasText); }
</style>
<span part="mark" aria-hidden="true"></span>
<slot></slot>
`;
class TermsCheckbox extends HTMLElement {
static formAssociated = true;
static observedAttributes = ['checked', 'disabled', 'value'];
#internals = this.attachInternals();
#defaultChecked = false;
#initialized = false;
constructor() {
super();
const root = this.shadowRoot ?? this.attachShadow({ mode: 'open' });
if (!root.firstChild) root.append(template.content.cloneNode(true));
this.addEventListener('click', this.#onClick);
this.addEventListener('keydown', this.#onKeyDown);
}
connectedCallback(): void {
if (!this.#initialized) {
this.#defaultChecked = this.checked;
this.#initialized = true;
}
this.#sync();
}
attributeChangedCallback(): void {
this.#sync();
}
get checked(): boolean { return this.hasAttribute('checked'); }
set checked(next: boolean) { this.toggleAttribute('checked', next); }
get disabled(): boolean { return this.hasAttribute('disabled'); }
get value(): string { return this.getAttribute('value') ?? 'on'; }
formResetCallback(): void {
this.checked = this.#defaultChecked;
}
formStateRestoreCallback(state: string | null): void {
this.checked = state === 'checked';
}
#sync(): void {
this.#internals.role = 'checkbox';
this.#internals.ariaChecked = String(this.checked);
this.#internals.ariaDisabled = String(this.disabled);
this.tabIndex = this.disabled ? -1 : 0;
this.#internals.setFormValue(
this.disabled || !this.checked ? null : this.value,
this.checked ? 'checked' : 'unchecked',
);
}
#toggleFromUser(): void {
if (this.disabled) return;
this.checked = !this.checked;
this.dispatchEvent(new Event('input', { bubbles: true, composed: true }));
this.dispatchEvent(new Event('change', { bubbles: true }));
}
#onClick = (): void => this.#toggleFromUser();
#onKeyDown = (event: KeyboardEvent): void => {
if (event.key !== ' ') return;
event.preventDefault();
this.#toggleFromUser();
};
}
Used as <terms-checkbox name="terms">I accept the terms</terms-checkbox>, the light text supplies the control’s name, the host participates in keyboard focus, and checked state contributes the configured value. A production version should add required-state validation with setValidity, handle form-disabled state, avoid toggling when a nested interactive slotted child is activated, and verify label association in supported browsers.
The important lesson is not to emulate native controls casually. Keyboard behavior, focus, disabled semantics, reset, state restoration, validation messages, high-contrast rendering, and accessible names form one contract. When customization needs are modest, a native <input type="checkbox"> styled around its supported behavior is usually the safer choice.
Render on the Server and Upgrade Without Replacing Content
An undefined custom element is still an HTML element. The parser preserves its attributes and light children, then upgrades it when the matching class is defined. This gives custom elements a natural progressive boundary, but a shadow-only component may appear incomplete before upgrade.
Declarative Shadow DOM allows the server to include an initial shadow tree:
<terms-checkbox name="terms" checked>
<template shadowrootmode="open">
<style>/* critical component styles */</style>
<span part="mark" aria-hidden="true"></span>
<slot></slot>
</template>
I accept the terms
</terms-checkbox>
Supporting browsers attach that template as the host’s shadow root during parsing. The constructor in the example uses this.shadowRoot ?? this.attachShadow(...), so it adopts server markup rather than throwing or replacing it. Upgrade should attach behavior and reconcile state while preserving focus, selection, scroll position, and already rendered content.
Keep browser globals out of code that executes during server rendering. Registration belongs in a client entry point guarded by environment capabilities. Component modules that create templates at top level also require document, so either load them only in the browser or represent server markup through the server’s normal HTML mechanism.
Server and client must agree on attributes, part names, and shadow structure. Version the server renderer with the element implementation, and test delayed upgrade. A usable light-DOM fallback is valuable when scripts fail; a control whose only label or content exists in an unavailable shadow tree has a fragile failure mode.
Integrate with Frameworks and Verify the Boundary
Framework integration becomes straightforward when the contract follows DOM conventions. Pass scalar declarative state through attributes, structured data through properties, and outcomes through bubbling composed events. Do not serialize arbitrary objects into attributes merely to satisfy a template syntax. A thin framework wrapper can assign properties, translate events into framework callbacks, expose a typed ref, and centralize server-safe registration.
Avoid feedback loops. If a framework owns checked, an incoming property update should change the element without emitting a user change event. Only user actions emit that outcome; the framework then updates its state and sends the value back. This controlled-component rule makes causality clear.
Compatibility tests should exercise the public surface, not private selectors. In a real browser, verify initial attributes, property assignment, upgrade after markup, connect-disconnect-connect behavior, event bubbling across the shadow boundary, slot replacement, CSS variables and parts, form submission/reset/restoration, keyboard operation, focus visibility, and accessible name and state. Browser tests are important because lightweight DOM emulators often implement Shadow DOM, custom-element upgrade, ElementInternals, and accessibility incompletely.
Run integration fixtures in each supported framework, including its server-rendering path. Test delayed module loading and two component instances from different application regions. Add manual checks with keyboard navigation, browser zoom, forced colors, and representative screen readers; automated accessibility rules catch missing semantics but cannot establish that a composite control is understandable.
The recurring failure modes are contract failures: duplicate registration, listeners accumulated on reconnect, attributes and properties drifting apart, non-composed events disappearing at the host, private markup becoming a styling dependency, form values omitted, inaccessible custom controls, and client upgrade replacing server content. Each can be turned into a boundary test.
Web Components work best when they are modest about what they own. Let the host framework own application state and light-DOM composition. Let the element own a stable behavior and shadow implementation. Expose platform-shaped inputs, outcomes, semantics, and styling hooks, then test them in the environments where the boundary matters. That is what makes encapsulation durable rather than merely hidden.