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.
- 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} × {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' });
}
})}@hyzer-labs/ui/motion. Reach for reveal or revealGroup there before reaching for raw intersect, unless you need something they do not offer.