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.
connectedCallbackruns every time the element is inserted — including after a move — anddisconnectedCallbackevery 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
IntersectionObserverorResizeObservercan observe an element inside a shadow root perfectly well — geometry is geometry — but code outside the component cannot find those elements withquerySelector. The component must observe its own internals. - Slotted content belongs to the light DOM. A
MutationObserveron 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 forslotchange.
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
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
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
// 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
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(); }
}
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 |
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.
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 (
visibilitychangecustom event withbubbles: true, composed: true) let any framework listen. Usecomposed: trueso events cross shadow boundaries if the component is nested.
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.
Related
- Building a Lit Reactive Controller for ResizeObserver — the controller pattern in depth
- Observing Elements Inside Shadow DOM — boundaries and slots
- Lazy Upgrading Custom Elements on Visibility — defining components when they scroll in
- disconnectedCallback Observer Cleanup in Custom Elements — lifecycle correctness
- Angular Observer Directives — another element-scoped model