Limitations and Workarounds
Web components have platform-level limitations that affect accessibility, typing, developer experience, and lifecycle behavior. Each section below describes a known limitation, the available workaround, and its tradeoffs.
Form Components and Accessibility
Problem
When building a form component, the actual input element lives inside the shadow DOM, not on the host element. As a result, accessibility attributes set on the host, such as aria-label, aria-labelledby, aria-describedby, and associated <label> elements, are not automatically forwarded to the internal form element.
Workaround
Set the host to display: contents so it does not render as a box in the layout and only its children are visible to the browser:
:host { display: contents;}Then in connectedCallback, hide the host from screen readers and forward the aria relationships to the internal form element:
class MyFormComponent extends HTMLElement { connectedCallback(): void { this.role = "presentation";
this.#customFormElement.ariaLabelledByElements = [this, ...(this.ariaLabelledByElements ?? [])]; this.#customFormElement.ariaDescribedByElements = [this, ...(this.ariaDescribedByElements ?? [])]; }}Tradeoffs
ariaLabelledByElementsandariaDescribedByElementsare relatively new APIs with limited browser support.display: contentson custom elements is also relatively new.- Forwarding only happens once when the element connects to the DOM. Elements added to
aria-labelledbyoraria-describedbyafter that point will not be forwarded.
Properties Set Before Element Upgrade
Problem
Some frameworks, notably Angular, and direct DOM manipulation may assign properties to a component before the custom element class has been upgraded. In that case, values are written directly onto the element instance as plain properties, bypassing any setters or reactive logic.
Workaround
Apply the property upgrade pattern in connectedCallback to capture and re-apply any properties that were set early:
class MyComponent extends HTMLElement { #propsToUpgrade = Object.entries(this) as [keyof this, this[keyof this]][] | undefined;
connectedCallback() { if (this.#propsToUpgrade) { for (const [prop, value] of this.#propsToUpgrade) { delete this[prop]; this[prop] = value; } this.#propsToUpgrade = undefined; } }}Deleting the plain property and re-assigning it causes the setter to run, so attribute parsing and reactive logic execute correctly regardless of when the property was first assigned.
Tradeoffs
- This pattern re-assigns all properties found on the instance at initialization, not just the ones your component declares. Unrelated properties attached externally will also be re-assigned.
DOM Children Unavailable in connectedCallback
Problem
Do not assume child elements are available when connectedCallback fires. If the component script is loaded synchronously via a blocking <script> tag, the element may initialize before the parser has processed its children:
<!-- Blocking script: children may not exist yet when connectedCallback runs --><script src="path/to/web-component.js"></script>
<my-component> <span class="child"></span></my-component>class MyComponent extends HTMLElement { connectedCallback() { const child = this.querySelector(".child"); // May be null }}Workaround
- Slotted children: listen for the
slotchangeevent on the relevant<slot>. This fires reliably once slotted children are available, regardless of how the script was loaded. - Light DOM children: use
DOMContentLoaded, but check if the document is already loaded first and run immediately if so:
class MyComponent extends HTMLElement { connectedCallback() { if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", () => this.#init(), { once: true }); } else { this.#init(); } }
#init() { const child = this.querySelector(".child"); }}Tradeoffs
slotchangeonly covers direct slotted children. Nested light DOM elements inside a slot are not guaranteed to be available when it fires.
Flash of Unstyled Content
Problem
When a component’s script has not loaded by the time the DOM is parsed, the element renders without any styling or behavior until the definition is registered, causing a visible flash of unstyled content (FOUC).
Workaround A: Blocking Script
Load the component using a classic blocking <script> tag placed before the component is used in the markup. This ensures the definition is registered before the element is parsed:
<script src="path/to/my-component.js"></script><my-component></my-component>Tradeoff: Not all frameworks allow control over script loading order. Loading scripts this way also blocks page rendering, which can slow down the initial page load.
Workaround B: Hide Until Defined
Use the :not(:defined) CSS selector to hide the element until its definition is registered:
my-component:not(:defined) { display: none;}Tradeoff: Hiding the element until it is defined introduces layout shift. The space it occupies suddenly appears once the definition loads, disrupting the layout and affecting user experience.
Writing HTML and CSS as Strings
Problem
Writing HTML and CSS inline as strings in a web component provides no type checking, no linting, no minification, and no tooling support. Errors are silent until runtime.
Workaround
Use import_as_string(filePath) to inline file contents as a string at build time. The build tool replaces the call with the actual file contents, so you get linting and type checking in the source files, and the output is processed and minified by the build tool:
class MyComponent extends HTMLElement { static readonly #htmlFragment = (() => { const template = document.createElement("template"); template.innerHTML = import_as_string("./my-component.html"); return template.content; })();
static readonly #stylesheet = (() => { const sheet = new CSSStyleSheet(); sheet.replace(import_as_string("./my-component.css")).catch(console.error); return sheet; })();}TypeScript Types
Problem
Getting accurate types for a custom element is not straightforward. Unlike TSX, where prop types are picked up automatically, custom elements require separate type definitions for each target: native DOM APIs (querySelector, createElement) and each supported framework. Without this, consumers get no type safety on properties, attributes, or events.
Workaround
Export a [ComponentName]Types type from your component file using WCP.WComponent. Use ExtendAttribute to account for attributes that TypeScript cannot infer from the class alone, such as attributes with string union constraints or attributes whose type differs from their related property:
import type { WCP } from "@staticbolt/wcp";
type ExtendAttribute = { orientation: "horizontal" | "vertical";};
export type SegmentedControlTypes = WCP.WComponent<typeof SegmentedControl, ExtendAttribute>;
class SegmentedControl extends HTMLElement implements WCP.IWebComponent {}The CLI uses this export to generate .d.ts files for each target. Setup instructions for native DOM types and each framework are covered in their respective sections under Setup.
Tradeoffs
- Types are generated, not inferred. They must be regenerated whenever the component API changes.
- The
[ComponentName]Typesexport must be kept in sync with the actual component for the generated output to remain accurate.