Skip to content

Observers

@hyzer-labs/ui/observers wraps the three platform observer APIs (IntersectionObserver, ResizeObserver, MutationObserver) as Svelte attachments: intersect, resize, mutate. Each one creates its observer when the element mounts and disconnects it when the element is removed, so there is no setup or teardown to write yourself. All three also take { once: true } to disconnect after the first delivery. The module also exports announce, which sends a message to a visually hidden live region. It pairs well with an observer callback that has just loaded more content or spotted a change.

Intersect

intersect(callback, options) creates one IntersectionObserver and passes root, rootMargin, and threshold straight through. Your callback runs with the entry every time the observer fires, on the way in and on the way out. The sentinel sits between two lists, so scroll it in and out: the corner badge tracks isIntersecting live in both directions, and crossing into view sends an announcement.

Is intersecting: no
  • Result 1
  • Result 2
  • Result 3
  • Result 4
  • Result 5
  • Result 6
  • Result 7
  • Result 8
  • Result 9
  • Result 10
  • Result 11
  • Result 12
  • Result 13
  • Result 14
  • Result 15
  • Result 16
import { intersect, announce } from '@hyzer-labs/ui/observers';

let intersecting = $state(false);
let count = $state(0);

<span>Is intersecting: {intersecting ? 'yes' : 'no'}</span>

<div
	class="sentinel"
	{@attach intersect((entry) => {
		intersecting = entry.isIntersecting; // fires BOTH ways
		if (!entry.isIntersecting) return;
		count += 1;
		announce(`Revealed ${count} times`);
	}, { root: paneEl, threshold: 0.6 })}
>
	Scroll me in and out of view
</div>

The observer delivers an initial entry as soon as it starts watching, and that entry may report isIntersecting: false. That is expected, and it is not filtered out. { once: true } disconnects after the first entry that actually intersects, never on that initial report.

Resize

resize(callback, options) creates one ResizeObserver and reports the observed box back to you on every delivery. Drag the corner below, or resize your window: the readout tracks the element's own live dimensions.

0 × 0px

Drag the bottom-right corner to resize me.

import { resize } from '@hyzer-labs/ui/observers';

<div
	{@attach resize((entry) => {
		const box = entry.borderBoxSize?.[0];
		width = box?.inlineSize ?? entry.contentRect.width;
		height = box?.blockSize ?? entry.contentRect.height;
	}, { box: 'border-box' })}
>
	{width} &times; {height}px
</div>

Mutate

mutate(callback, options) creates one MutationObserver and passes the native MutationObserverInit straight to observe(). Your callback receives the whole array of MutationRecords for that delivery, not a single entry, because one delivery is inherently multi-record. Reach for it when the DOM you care about changes for reasons your component does not control. There is no $state to bind against, so an observer is the only way to notice. The region below is contenteditable: type or paste into it and the browser mutates its text nodes directly, with no Svelte reactivity involved. debounce gathers the burst of character mutations a fast typist produces into one callback after a quiet period. Toc uses the same option, so it does not re-collect its headings on every change inside content it does not own either.

Word count: 0 (unedited)

import { mutate } from '@hyzer-labs/ui/observers';

<!-- The browser mutates this DOM directly on user input. There is no
     $state bound to it, so mutate is what notices the change. -->
<div
	contenteditable="true"
	{@attach mutate(() => {
		wordCount = countWords(editorEl.textContent);
	}, { childList: true, characterData: true, subtree: true, debounce: 150 })}
></div>

Announce

announce(message, options) writes your message into a visually hidden live region. It creates two regions the first time you call it, one polite and one assertive, then reuses them. Use { assertive: true } for genuine interruptions; everything else should stay polite. It never moves focus and adds no role, so you can call it from anywhere, including an observer callback. The sentinel above calls it on every reveal. Try the buttons below with a screen reader running. The log underneath echoes what was sent, for sighted reference; the live region itself is off-screen.

import { announce } from '@hyzer-labs/ui/observers';

announce('Loaded 20 more results');
announce('Connection lost. Retrying…', { assertive: true });

Reduced motion

None of the three attachments read prefers-reduced-motion themselves. An observer firing is not motion, and plenty of uses (lazy-loading, analytics, a live width readout) should keep working for a reader who prefers less animation. If you are building your own observer-driven motion, guard the animation inside the callback with svelte/motion's prefersReducedMotion, the same mechanism revealGroup and Toc already use:

import { intersect } from '@hyzer-labs/ui/observers';
import { prefersReducedMotion } from 'svelte/motion';

{@attach intersect((entry) => {
	if (!entry.isIntersecting) return;
	if (prefersReducedMotion.current) {
		el.classList.add('is-visible'); // appear instantly, no animation
	} else {
		el.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 300, fill: 'forwards' });
	}
})}
Scroll entrances that already handle reduced motion live in @hyzer-labs/ui/motion. Reach for reveal or revealGroup there before reaching for raw intersect, unless you need something they do not offer.