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, forward the aria relationships to the internal form element:

class MyFormComponent extends HTMLElement {
#ariaObserver?: MutationObserver;
connectedCallback(): void {
const forwardAria = () => {
// Forward labels
const mayHaveOwnLabel = this.ariaLabel || this.id || this.closest("label");
this.#customFormElement.ariaLabelledByElements = this.ariaLabelledByElements ?? (mayHaveOwnLabel ? [this] : null);
this.#customFormElement.ariaDescribedByElements = this.ariaDescribedByElements;
// Avoid double read
const hasAriaLabelling = this.ariaLabel || this.ariaLabelledByElements || this.ariaDescribedByElements;
this.role = hasAriaLabelling ? null : "presentation";
};
forwardAria();
// Watch changes
this.#ariaObserver ??= new MutationObserver(forwardAria);
this.#ariaObserver.observe(this, { attributeFilter: ["id", "aria-label", "aria-labelledby", "aria-describedby"] });
}
}

The host's aria-label, aria-labelledby, aria-describedby, and <label> reach the internal element and stay up to date. See $forwardAria macro for more details.

Tradeoffs

  • Needs ariaLabelledByElements, Baseline since April 2025 (Chrome 135, Firefox 136, Safari 16.4).
  • An unlabelled host with an id is named after its content.
  • If aria-labelledby names an id that does not exist yet, adding that element later has no effect.
  • With aria-* on the host, screen readers may read the label twice.

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. Type a boolean attribute as SV.BooleanAttribute: it is on whenever it is present, whatever its value:

import type { SV } from "@staticview/ui";
type ExtendAttribute = {
orientation: "horizontal" | "vertical";
disabled: SV.BooleanAttribute;
};
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.

Accessor Types

TypeScript records a separate write type for an accessor whose setter accepts more than its getter returns, but that type is reachable only in an assignment expression. Every other route to it — an indexed access, a keyof, any mapped type — yields the getter type, and WComponent reads the class through mapped types. There is no type operator that extracts a setter type, so a divergent one has to be declared by hand:

type OverriddenProperties = {
for: HTMLElement | HTMLElement[] | string | null;
};
export type TooltipTypes = SV.WComponent<typeof Tooltip, ExtendAttribute, OverriddenProperties>;
class Tooltip extends HTMLElement implements SV.IWebComponent {
get for(): HTMLElement[] {
return this.#anchorElements;
}
set for(value: HTMLElement | HTMLElement[] | string | null) {
this.#setAnchorElements(value);
}
}

Without it, consumers cannot assign a selector string to for through any generated framework type. Pass never in the second position when the component has no extended attributes.

The generated API reference reads both accessors directly from the class, so it reports the setter type as Setter Type on its own.

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.
  • A divergent setter type is invisible to the type system, so OverriddenProperties has to be maintained by hand alongside the accessor.