Angular Signals cleaned up state management. It didn't clean up the DOM.
Every time I needed to know whether an element was on screen, I wrote the same directive again. Set up an IntersectionObserver in ngOnInit, disconnect it in ngOnDestroy, call markForCheck() so change detection actually notices, and wrap the whole thing in an isPlatformBrowser check so the server doesn't blow up on a global that doesn't exist there.
That's a lot of ceremony to answer "is this thing visible yet."
The alternative was pulling in a third-party directive, which usually meant a dependency tree I didn't want and SSR behavior I had to verify myself.
So I wrote ngx-viewport-signals. Four primitives, no dependencies, and the observers clean themselves up.
The four primitives
import { ElementRef, inject } from '@angular/core';
import { inViewport, viewportRatio, elementSize, scrollProgress } from 'ngx-viewport-signals';
class HeroSection {
private readonly el = inject(ElementRef);
visible = inViewport(this.el, { threshold: 0.3, once: true });
ratio = viewportRatio(this.el);
size = elementSize(this.el);
progress = scrollProgress(this.el);
}
inViewport() is a boolean. Pass once: true for entrance animations and it disconnects the observer after the first hit, instead of leaving a live one running for the rest of the page's life.
viewportRatio() gives you the raw 0..1 intersection ratio when a boolean isn't enough.
elementSize() is a { width, height } signal backed by ResizeObserver.
scrollProgress() is 0..1 as the element travels through the viewport. Useful for progress bars and parallax.
All four accept an ElementRef, a raw Element, or an accessor function. That last one matters: it means a viewChild() signal query drops straight in, no AfterViewInit dance.
The part I actually care about: teardown
Three of the four primitives share one skeleton, and the whole lifecycle story is a single effect():
effect((onCleanup) => {
const el = elementSignal();
if (!el) return;
const observer = createObserver();
observer.observe(el);
onCleanup(() => {
observer.unobserve(el);
observer.disconnect();
});
});
onCleanup fires in both situations that matter: before the effect re-runs because the element changed, and when the injector is destroyed. One hook, both cases. No DestroyRef wiring, no ngOnDestroy, no leaked observer when a viewChild() swaps out from under you.
SSR
The guard sits at the top of the factory, not sprinkled through the code:
const value = signal(initial);
if (!isBrowserPlatform()) {
return value.asReadonly();
}
On the server you get a static signal holding the default and nothing else runs. IntersectionObserver is never touched. Your components don't need platform checks of their own.
Why scrollProgress is different
The other three are one observer with a callback. scrollProgress isn't — you need a value on every frame, and IntersectionObserver only fires on threshold crossings.
So it runs a requestAnimationFrame loop, gated by an observer:
new IntersectionObserver(([entry]) => {
active = entry.isIntersecting;
if (active && rafId === null) {
tick(entry.target);
} else if (!active && rafId !== null) {
cancelAnimationFrame(rafId);
rafId = null;
}
}, { threshold: 0, rootMargin: '0px' });
The rAF loop only spins while the element is actually intersecting. Scroll past it and the loop stops dead. Ten of these on a page cost you nothing when they're all off screen — which is most of the time.
It didn't fit the shared skeleton, so I left it bespoke rather than bending the abstraction to cover it.
Config is optional
providers: [
provideViewportSignals({ defaultRootMargin: '0px', defaultThreshold: 0 })
]
Set a baseline rootMargin once if you want. Skip it entirely and everything still works — there's a default injection value, so nothing throws when the provider isn't there.
Try it
- Demo: ysndmr.github.io/ngx-viewport-signals
- GitHub: github.com/ysndmr/ngx-viewport-signals
-
Install:
npm i ngx-viewport-signals
Angular 17 through 21, MIT, zero runtime dependencies.
If you're doing scroll-driven animation, lazy-loading heavy components, or anything that reacts to layout, give it a shot. I'm curious how you're handling viewport observation right now — the hand-rolled directive is still the most common answer I get, and I'd like to know if that's changing.
Originally published on ysndmr.com.
Top comments (0)