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.
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
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
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.
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-visibleevent 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.
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: nullfor a scrolling component. Visibility is then relative to the page, not the component's scroller. - Observing the shadow root for slot changes. Listen for
slotchangeor 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.
Related
- Building a Lit Reactive Controller for ResizeObserver — packaging the resize part
- Using a Scroll Container as IntersectionObserver Root — custom roots in general
- Detecting Added and Removed Nodes with MutationObserver — reading child-list records
↑ Back to Web Component & Lit Observer Controllers