IntersectionObserver and ResizeObserver can observe any element inside a shadow root once you hold a reference to it — geometry ignores the boundary — but MutationObserver only sees mutations within the tree it observes, so shadow internals, slotted children and nested shadow roots each need to be observed where they actually live.

Problem / Scenario Context

A team builds <image-carousel> as a web component. Its slides are passed in as light-DOM children and slotted into a scrollable track inside the shadow root. They want three behaviours: lazy-load slide images as they scroll into the track (not the page viewport), resize the track's height to the tallest visible slide, and re-initialise when slides are added or removed. The first attempt uses this.shadowRoot.querySelectorAll('img') to find images (finds none — they are slotted), an observer with root: null (fires for off-screen slides that are inside the page viewport but outside the track) and a MutationObserver on the shadow root (never fires when slides change).

Each failure is about where the thing being observed actually lives. The Web Component & Lit Observer Controllers topic covers the lifecycle; this page covers the boundaries.

Mechanics Explanation

A shadow root is a separate DOM tree attached to a host element. For observers, three different rules apply:

Queries stop at the boundary. document.querySelector does not enter shadow roots, and shadowRoot.querySelector does not return slotted light-DOM children — those remain children of the host. To find slotted elements, use slot.assignedElements({ flatten: true }).

Geometry crosses the boundary. Layout is computed for the flat tree, in which slotted children are rendered inside the slot's position. An IntersectionObserver or ResizeObserver observing a slotted image sees its real, rendered geometry. An observer's root can be an element inside the shadow root — such as the carousel's scroll track — and targets must be descendants of that root in the flat tree, which slotted children are.

Mutations stay in their tree. MutationObserver with subtree: true on a shadow root reports changes within the shadow tree. Adding or removing a slotted child is a change to the host's child list in the light DOM. Changes inside a nested component's shadow root are invisible from outside it. The slotchange event fires on a slot when its assigned nodes change, which is usually what a component wants.

What Crosses the Shadow BoundaryTwo columns. Things that cross the boundary include layout geometry, so IntersectionObserver and ResizeObserver work on shadow internals and slotted children, and an observer root inside the shadow root for slotted targets. Things that do not cross include querySelector from outside, shadowRoot querySelector for slotted children, MutationObserver on the shadow root for slotted changes, and any observer into a nested component's shadow root.Crosses the boundaryGeometry: IO and RO see real rendered boxesA shadow-internal root can contain slotted targetsslotchange reports slot assignment changesDoes not crossdocument.querySelector into shadow rootsshadowRoot.querySelector for slotted childrenMutationObserver on shadow root for slot changesAny access into a nested closed shadow root

Comparison Table: Observing Each Kind of Node

Target How to find it Observe with Root / scope
Host element this IO / RO viewport or ancestor scroller
Shadow internal part this.shadowRoot.querySelector IO / RO any, including shadow scroller
Slotted child slot.assignedElements({ flatten: true }) IO / RO shadow scroller works (flat tree)
Slot assignment changes slot element slotchange event
Changes inside slotted children host element MutationObserver on host, subtree: true light DOM
Nested component internals that component its own observers its own shadow root

Minimal Reproducible Example

TypeScript
class ImageCarousel extends HTMLElement {
  connectedCallback(): void {
    const root = this.attachShadow({ mode: 'open' });
    root.innerHTML = `<div class="track"><slot></slot></div>`;
    const imgs = root.querySelectorAll('img');                         // 0 — images are slotted
    const io = new IntersectionObserver(load);                          // root: viewport, not track
    imgs.forEach((i) => io.observe(i));
    new MutationObserver(() => console.log('slides changed'))
      .observe(root, { childList: true, subtree: true });              // never fires for slides
  }
}

declare function load(entries: IntersectionObserverEntry[]): void;

Production-Safe Solution

TypeScript
class ImageCarousel extends HTMLElement {
  #root = this.attachShadow({ mode: 'open' });
  #io: IntersectionObserver | null = null;
  #ro = new ResizeObserver(() => this.#fitHeight());
  #mo = new MutationObserver(() => this.#fitHeight());   // content changes inside slides
  #slot!: HTMLSlotElement;
  #track!: HTMLElement;

  constructor() {
    super();
    this.#root.innerHTML = `
      <style>.track { display: flex; align-items: flex-start; overflow-x: auto; scroll-snap-type: x mandatory; }</style>
      <div class="track" part="track"><slot></slot></div>`;
    this.#slot = this.#root.querySelector('slot')!;
    this.#track = this.#root.querySelector('.track')!;
  }

  connectedCallback(): void {
    // Root is the shadow-internal scroller; slotted slides are its flat-tree descendants.
    this.#io = new IntersectionObserver((entries) => {
      for (const e of entries) {
        if (!e.isIntersecting) continue;
        e.target.querySelectorAll<HTMLImageElement>('img[data-src]').forEach((img) => {
          img.src = img.dataset.src!; img.removeAttribute('data-src');
        });
        this.#io!.unobserve(e.target);
      }
    }, { root: this.#track, rootMargin: '0px 50% 0px 50%' });   // half a slide ahead

    this.#slot.addEventListener('slotchange', this.#onSlotChange);
    this.#mo.observe(this, { childList: true, subtree: true, characterData: true });
    this.#onSlotChange();
  }

  disconnectedCallback(): void {
    this.#slot.removeEventListener('slotchange', this.#onSlotChange);
    this.#io?.disconnect(); this.#io = null;
    this.#ro.disconnect();
    this.#mo.disconnect();
  }

  #onSlotChange = (): void => {
    // Re-register every slide: slotchange fires on initial assignment and on every change.
    this.#ro.disconnect();
    for (const slide of this.#slot.assignedElements({ flatten: true })) {
      this.#io?.observe(slide);          // observing twice is a no-op
      this.#ro.observe(slide);
    }
    this.#fitHeight();
  };

  #fitHeight(): void {
    const slides = this.#slot.assignedElements({ flatten: true }) as HTMLElement[];
    const tallest = Math.max(0, ...slides.map((s) => s.offsetHeight));
    this.#track.style.blockSize = `${tallest}px`;
  }
}
customElements.define('image-carousel', ImageCarousel);

The intersection root is the shadow-internal .track, so slides report visibility relative to the carousel's own scrollport. Slides are found through assignedElements, re-registered on every slotchange, and their inner changes (an image loading and changing height) reach the component through a MutationObserver on the host and a ResizeObserver on each slide.

Writing the track's blockSize from #fitHeight is safe inside the resize callback because the track is not itself observed: the observed slides are its descendants, so the change does not re-trigger their observations. That only holds because the track uses align-items: flex-start: with the default stretch, the slides would grow to the track's new height, report a resize, and the "tallest slide" could never shrink again.

The Carousel's Observers and Where They AttachFour stacked layers. The IntersectionObserver uses the shadow-internal track as its root and observes each slotted slide. The ResizeObserver observes each slotted slide to fit the track height. The slotchange listener on the slot re-registers slides when they are added or removed. The MutationObserver on the host sees changes inside the slides' light DOM content.IO, root = .trackObserves each slotted slide; lazy-loads images half a slide ahead.RO on each slideFits the track's height to the tallest slide.slotchange on slotRe-registers slides whenever they are added or removed.MO on hostSees text and element changes inside slides (light DOM).

Nested Components and Closed Roots

When the carousel's slides are themselves web components with their own shadow roots — a <product-card> per slide, say — the carousel can still observe each card's host element for visibility and size, because geometry is flat. It cannot see into each card's shadow root, and it should not try: the card is responsible for observing its own internals.

That division of responsibility generalises:

  • A component observes its own host and its own shadow internals.
  • A container component observes its slotted children as black boxes — their host elements only.
  • Communication crosses boundaries through events and attributes, not through reaching into another component's tree. A card that needs to know it is visible inside a carousel can listen for a custom slide-visible event the carousel dispatches on it, or reflect an attribute the carousel sets.

With mode: 'closed', element.shadowRoot returns null to everyone, including the component's own later code, so keep the root in a private field as above.

Responsibilities Across Nested ComponentsThree boxes. The carousel observes each slotted product card's host element for visibility and size. When a card becomes visible, the carousel sets an attribute or dispatches an event on it. The product card reacts internally and observes its own shadow parts, which the carousel never touches.Carouselobserves card hosts onlySignal acrossattribute or event on the cardProduct cardobserves its own internals

Verification Steps

  • Log assignedElements() after connection and after adding a slide to confirm registration.
  • Scroll the page, not the carousel, and confirm off-screen slides do not load (the track is the root).
  • Scroll the carousel and confirm the next slide loads half a slide early.
  • Change text inside a slide and confirm the track height updates.
  • Remove the carousel and confirm all observers and the slot listener are released.

Common Mistakes to Avoid

  • Querying the shadow root for slotted children. They are in the light DOM; use assignedElements.
  • Leaving root: null for a scrolling component. Visibility is then relative to the page, not the component's scroller.
  • Observing the shadow root for slot changes. Listen for slotchange or observe the host.
  • Reaching into nested components' shadow roots. Observe their hosts and let them handle their internals.

FAQ

Can the IntersectionObserver root be inside a shadow root while targets are slotted?

Yes. Containment is evaluated in the flat tree, where slotted children render inside the slot, so they are descendants of a scroller that wraps the slot.

Does slotchange fire on the initial render?

Yes, when nodes are first assigned to the slot after the shadow root and slot exist. It is safe to rely on it for initial registration, though calling the handler once on connect as well does no harm.

Why does my MutationObserver on the shadow root miss slide changes?

Because slides are children of the host in the light DOM. Adding or removing them changes the host's child list, not the shadow tree. Observe the host or listen for slotchange.

Can outside code observe a component's internal parts?

Only if the component exposes them, for example through a property returning the element. CSS parts (the part attribute) expose styling, not DOM references, so they do not help observers.

What does flatten: true do in assignedElements?

If a slotted child is itself a slot (when components forward slots through each other), flatten follows the chain and returns the elements ultimately assigned, which are the ones that actually render.


↑ Back to Web Component & Lit Observer Controllers