Skip to content

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 ?? [])];
}
}

See $forwardAria macro for more details.

Tradeoffs

  • ariaLabelledByElements and ariaDescribedByElements are relatively new APIs with limited browser support.
  • display: contents on custom elements is also relatively new.
  • Forwarding only happens once when the element connects to the DOM. Elements added to aria-labelledby or aria-describedby after that point will not be forwarded.

Focusing Components with Shadow DOM

Problem

When a component’s focusable control is inside its shadow DOM, the browser can reach that control during sequential keyboard navigation. Calling focus() on the host, however, does not automatically move focus to the internal control.

Enabling focus delegation makes programmatic focus work, but a custom element host is not part of the sequential focus order by default. Without a tabindex, keyboard users cannot discover the host itself as a focus target.

Workaround

Enable delegatesFocus when attaching the shadow root so focus placed on the host is forwarded to the first focusable element inside it:

class MyFocusableComponent extends HTMLElement {
readonly #shadow = this.attachShadow({
mode: "open",
delegatesFocus: true,
});
connectedCallback(): void {
// Expose the host as a keyboard focus target. delegatesFocus forwards
// focus to the internal control.
if (!this.hasAttribute("tabindex")) {
this.tabIndex = 0;
}
}
}

Setting the default in connectedCallback makes the host discoverable through Tab navigation while preserving an explicit tabindex supplied by the component’s user, including tabindex="-1".

Tradeoffs

  • Focus is delegated to the first focusable element in shadow-tree order. Components with multiple focusable elements must ensure that this is the intended initial target.
  • delegatesFocus can only be configured when the shadow root is attached.

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 to detect and re-apply any properties that were set early, routing them through their setters:

class MyComponent extends HTMLElement {
// Must be declared first — captures all own properties set before upgrade,
// before any class field initializers overwrite them.
#capturedProperties = Object.entries(this) as [keyof this, this[keyof this]][];
connectedCallback() {
for (const [property, value] of this.#capturedProperties) {
// Remove the plain own property so the prototype accessor is no longer shadowed.
delete this[property];
// Re-assigning routes through the real setter (or restores the plain property),
// so reactive logic runs as if the value had been set after definition.
this[property] = value;
}
// Clear the array — subsequent connectedCallback calls (e.g. re-attach) are no-ops.
this.#capturedProperties.length = 0;
}
}

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.

Alternatively, you can use the $upgrade macro:

class MyComponent extends HTMLElement {
connectedCallback() {
$upgrade();
}
}

See $upgrade macro for more details.

Tradeoffs

  • #capturedProperties must be the first declared field in the class. Any field declared before it will have its initializer run first, overwriting the pre-upgrade value before it can be captured.
  • Only enumerable own properties are captured. Properties set via Object.defineProperty with enumerable: false before upgrade are not handled, though this is uncommon in practice.

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 slotchange event 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

  • slotchange only 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 $inline(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 = $inline("./my-component.html");
return template.content;
})();
static readonly #stylesheet = (() => {
const sheet = new CSSStyleSheet();
sheet.replace($inline("./my-component.css")).catch(console.error);
return sheet;
})();
readonly #shadow = (() => {
const shadow = this.attachShadow({ mode: "open" });
shadow.adoptedStyleSheets = [MyComponent.#stylesheet];
shadow.append(MyComponent.#htmlFragment.cloneNode(true));
return shadow;
})();
}

See $inline and $shadow macros for more information.


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 SV.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 { SV } from "@staticview/ui";
type ExtendAttribute = {
orientation: "horizontal" | "vertical";
};
export type SegmentedControlTypes = SV.WComponent<typeof SegmentedControl, ExtendAttribute>;
class SegmentedControl extends HTMLElement implements SV.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]Types export must be kept in sync with the actual component for the generated output to remain accurate.