Custom elements have the cleanest lifecycle hooks of any component model — connectedCallback and disconnectedCallback fire exactly when an element enters and leaves the document — and the messiest boundaries: shadow roots hide internals from outside observers, slotted content lives in the light DOM, and an element can be moved, re-inserted or upgraded long after it was parsed.

Concept Framing

The rest of Framework Integration & Observer Adapters wraps observers in framework constructs. Web components are the platform's own component model, used directly or through lightweight libraries such as Lit, and they are increasingly the way design systems ship components that must work inside React, Vue, Angular and plain HTML alike. Observers are a natural fit: a <data-table> needs a ResizeObserver to pick column widths, a <lazy-embed> needs an IntersectionObserver to load late, and a <rich-editor> needs a MutationObserver on its slotted content.

Three properties of custom elements shape how observers should be attached:

  • Connection is not construction. The constructor runs when the element is created or upgraded; it may not be in the document yet and must not touch attributes or children. connectedCallback runs every time the element is inserted — including after a move — and disconnectedCallback every time it is removed. Observers belong in the connect/disconnect pair, not the constructor.
  • Shadow DOM is a boundary for queries, not for geometry. An IntersectionObserver or ResizeObserver can observe an element inside a shadow root perfectly well — geometry is geometry — but code outside the component cannot find those elements with querySelector. The component must observe its own internals.
  • Slotted content belongs to the light DOM. A MutationObserver on the shadow root does not see changes to slotted children; those live in the host's light DOM. Either observe the host's children or listen for slotchange.

Where Observers Attach in a Custom ElementFour layers of a custom element. The host element in the light DOM is where the element observes its own size and visibility. Its shadow root contains internal parts that only the component can find and observe. The slot element projects light DOM children, whose changes are reported by slotchange or a MutationObserver on the host. The lifecycle callbacks connect and disconnect all of these observations.Host element (light DOM)Observe the host itself for size and visibility; visible to outside code too.Shadow root internalsOnly the component can query and observe these parts.slot and slotted childrenChildren live in the light DOM; use slotchange or observe the host's childList.connected / disconnectedStart observing on connect, stop on disconnect — every time, including moves.

Spec / Signature Reference Table

The lifecycle and helper APIs that observer-bearing components use:

API When it runs Observer role
constructor() creation or upgrade create observer instances, do not observe yet
connectedCallback() each insertion, including moves observe() host and internals
disconnectedCallback() each removal unobserve() / disconnect()
attributeChangedCallback() observed attribute changes reconfigure (e.g. root-margin attribute)
adoptedCallback() moved to another document rarely needed; reconnection handles it
slotchange event assigned nodes of a slot change re-register slotted elements
Lit ReactiveController hostConnected / hostDisconnected / hostUpdate encapsulated observer logic
Lit @lit-labs/observers ready-made controllers IntersectionController, ResizeController, MutationController

Step-by-Step Implementation

The walk-through builds a vanilla custom element that reports its own visibility and size, then the same behaviour as a reusable Lit controller.

Step 1: Create observers in the constructor, observe on connect

TypeScript
class ObservedCard extends HTMLElement {
  #io = new IntersectionObserver(([e]) => this.toggleAttribute('in-view', e.isIntersecting),
    { rootMargin: '100px' });
  #ro = new ResizeObserver(([e]) => {
    const w = e.contentBoxSize[0].inlineSize;
    this.toggleAttribute('compact', w < 360);      // styling hook, no re-render
  });

  connectedCallback(): void {
    this.#io.observe(this);
    this.#ro.observe(this);
  }

  disconnectedCallback(): void {
    this.#io.disconnect();
    this.#ro.disconnect();
  }
}
customElements.define('observed-card', ObservedCard);

Creating the instances once and observing in connectedCallback means a card moved from one list to another disconnects and reconnects cleanly — no duplicated observers, no stale observation.

Step 2: Style from attributes the observers set

CSS
observed-card { display: block; container-type: inline-size; }
observed-card[compact] .meta { display: none; }
observed-card:not([in-view]) video { visibility: hidden; }

Reflecting observer state to attributes lets page CSS, the component's own :host([compact]) rules and outside frameworks all react without a JavaScript API.

Step 3: Share instances across many cards

TypeScript
// One observer for all cards, dispatching to the right element.
const cardIO = new IntersectionObserver((entries) => {
  for (const e of entries) (e.target as SharedCard).onVisibility(e.isIntersecting);
}, { rootMargin: '100px' });

class SharedCard extends HTMLElement {
  onVisibility(v: boolean): void { this.toggleAttribute('in-view', v); }
  connectedCallback(): void { cardIO.observe(this); }
  disconnectedCallback(): void { cardIO.unobserve(this); }
}
customElements.define('shared-card', SharedCard);

Because the target is the component instance, no WeakMap is needed to route entries — the element carries its own handler.

Step 4: The Lit controller version

TypeScript
import { ReactiveController, ReactiveControllerHost } from 'lit';

export class VisibilityController implements ReactiveController {
  visible = false;
  #io: IntersectionObserver;

  constructor(private host: ReactiveControllerHost & HTMLElement, init: IntersectionObserverInit = {}) {
    this.#io = new IntersectionObserver(([e]) => {
      if (e.isIntersecting === this.visible) return;
      this.visible = e.isIntersecting;
      this.host.requestUpdate();                     // re-render only on change
    }, init);
    host.addController(this);
  }
  hostConnected(): void { this.#io.observe(this.host); }
  hostDisconnected(): void { this.#io.disconnect(); }
}

Building an Observed Custom ElementFour steps. Create observer instances in the constructor without observing. Observe the host and internals in connectedCallback and stop in disconnectedCallback. Reflect observer state to attributes so CSS can react. Share a module-level observer across instances, using the element itself as the routing key.1ConstructCreate observer instances; do not observe yet.2Connect / disconnectobserve() on every insertion, disconnect() or unobserve() on every removal.3Reflect to attributesin-view and compact attributes drive CSS inside and outside the component.4Share across instancesOne module-level observer; the target element carries its own handler.

Threshold / Configuration Variants

Components are configured with attributes, so observer options should be too.

Attribute Maps to Notes
root-margin="200px" rootMargin re-create the observer on change (options are immutable)
threshold="0.5" threshold parse to number or array
loading="lazy" intersection-triggered upgrade mirror the native attribute's semantics
observe-size (boolean) enable ResizeObserver opt-in to avoid cost for static uses
box="border-box" ResizeObserverOptions.box per-observe option; no re-create needed

Which Observer Sees What Across the Shadow BoundaryA grid of three observers against three targets. IntersectionObserver and ResizeObserver can observe the host, shadow internals when observed from inside the component, and slotted children. MutationObserver on the shadow root sees internal changes but not slotted children, which require observing the host's child list or listening for slotchange.Host elementShadow internalsSlotted childrenIntersectionObserveryesyes, from insideyesResizeObserveryesyes, from insideyesMutationObserver on shadow rootnoyesnoMutationObserver on hostattributes, childrennoyesslotchange eventn/an/aassignment changes

Edge Cases & Gotchas

Moves trigger disconnect then connect. appendChild of an already-connected element to a new parent calls disconnectedCallback then connectedCallback. Code that disconnect()s in the first and never re-observes in the second breaks on every move. Keep observation strictly in connectedCallback. Newer engines add moveBefore(), which moves without the disconnect/connect pair and calls connectedMoveCallback if defined.

Upgrade timing. An element in the HTML before its definition is registered is upgraded later; its constructor and connectedCallback run at upgrade time, not at parse time. Observers therefore start late for such elements, which is often fine — and is the basis of lazy upgrading on visibility.

Closed shadow roots. With mode: 'closed', even the component's own code must keep the shadow root reference to observe internals; outside code has no access at all. Design components so that observation of internals is the component's responsibility.

Frameworks that re-create elements. React may unmount and remount a custom element on key changes; Vue's v-if does the same. The connect/disconnect discipline makes that safe, but state held only in the observer callback is lost — reflect it to attributes or properties if the host framework needs it.

Server-side rendering with Declarative Shadow DOM. The shadow root can arrive in HTML via <template shadowrootmode="open"> before any script runs. Observers still start only when the element's class is defined and connected, so initial styles must be correct without them.

Shipping Observer-Based Components in a Design System

Design systems built on web components face constraints that application code does not: the same component runs on pages with five instances and pages with five thousand, inside every framework, and sometimes inside iframes or email previews with no scripting at all. A few conventions keep observer-based components predictable across all of them.

Share instances per tag. A module-level observer per component type — one ResizeObserver for every <ds-table>, one IntersectionObserver for every <ds-lazy-image> — keeps cost flat as instance counts grow. Because the target is the component instance, routing entries back needs no lookup table.

Degrade to static. Every observer-driven behaviour should have a sensible state when the observer never fires: images show their fallback, tables use their default column widths, reveals are visible. Hosts that never run the component's script, and screenshot tools that never scroll, then still render something reasonable.

Expose state in three forms. Reflect it to an attribute for CSS, provide a read-only property for frameworks, and dispatch a composed event for listeners. Consumers pick whichever their framework handles best, and the component does not have to know who is listening.

Make options attributes. root-margin, threshold and lazy attributes let page authors tune behaviour without JavaScript and make the configuration visible in the Elements panel. Because observer options are immutable, the component re-creates its observation when these attributes change — or, for shared observers, moves the element to the pool keyed by the new options.

Document the lifecycle contract. State in the component's documentation that it observes on connect and releases on disconnect, so consumers know that moving an element is safe and removing it frees it.

Design-System Conventions for Observer ComponentsA grid of conventions and what each protects against. Sharing instances per tag protects against cost growing with instance count. Degrading to a static state protects hosts without script and screenshot tools. Exposing state as attribute, property and event protects framework interoperability. Making options attributes protects configurability without JavaScript.Protects againstExampleShare per tagcost growing with instancesone RO for all ds-tableDegrade to staticno script, no scrollingfallback image, default widthsAttribute + property + eventframework mismatchin-view, .visible, visibility-changeOptions as attributesJS-only configurationroot-margin="200px"

Framework Integration Patterns

Web components are often consumed inside frameworks, so their observer state should be readable in each:

  • Attributes (in-view, compact) work everywhere and in CSS.
  • Properties (card.visible) work for frameworks that set and read properties — Lit, Vue, Angular, React 19.
  • Events (visibilitychange custom event with bubbles: true, composed: true) let any framework listen. Use composed: true so events cross shadow boundaries if the component is nested.
TypeScript
this.dispatchEvent(new CustomEvent('visibility-change', {
  detail: { visible: e.isIntersecting },
  bubbles: true,
  composed: true,        // escape enclosing shadow roots
}));

React 19 supports custom element properties and events natively; earlier React versions need a ref and addEventListener.

Debugging Checklist

  • Remove elements and confirm disconnectedCallback
  • Check that the constructor does not call observe()

FAQ

Can an IntersectionObserver outside a component observe an element inside its shadow root?

Yes, if it has a reference to the element. Observers work on geometry and do not care about shadow boundaries. The difficulty is getting the reference, which outside code cannot do with querySelector across a shadow root.

Should I create observers in the constructor or connectedCallback?

Creating the instance in the constructor is fine; calling observe belongs in connectedCallback, and releasing in disconnectedCallback. That way moves and re-insertions work correctly.

What is a Lit reactive controller?

An object that hooks into a Lit element's lifecycle through hostConnected, hostDisconnected and update callbacks, and can request re-renders. It packages observer logic so any Lit component can reuse it by instantiating the controller.

Does Lit ship observer controllers?

The @lit-labs/observers package provides IntersectionController, ResizeController, MutationController and PerformanceController. They are a good default; write your own when you need shared instances across many components or custom batching.

How do I observe changes to slotted content?

Listen for slotchange on the slot element to learn when the set of assigned nodes changes, and use a MutationObserver on the host element (not the shadow root) to see changes within those light DOM children.

Should a component observe its own host or an inner wrapper?

Observe the host when the question is about the component as a whole — is it visible, how wide is it. Observe an inner part when that part has its own size, such as a scrolling body inside a fixed-height panel. Observing both is fine; they are separate targets in the same or different observers.

How do observers behave inside an iframe that hosts web components?

Each document has its own rendering steps and viewport. A component in an iframe observes relative to the iframe's viewport by default, and cross-origin iframes may be throttled when off-screen, so visibility callbacks inside them can lag the parent page.

Do observers keep working when a component is inside a closed details element?

The component is disconnected from layout rather than from the document, so connectedCallback does not re-run. IntersectionObserver reports it as not intersecting and ResizeObserver reports a zero size; when the details element opens, new entries arrive. Components should treat zero size as "hidden", not as an error.

Does disconnectedCallback always run when a page navigates away?

No. On full page unload nothing is guaranteed to run, but that does not matter for memory because the whole document is discarded. It does run for removals within a living document, including client-side route changes, which is where leaks occur.


↑ Back to Framework Integration & Observer Adapters