Scroll-triggered UI — pinned headers, reveal animations, active-section navigation, reading-progress bars — has traditionally been built on a scroll event listener that recalculates element positions on every pixel of movement. IntersectionObserver turns each of these effects into a boundary-crossing problem the browser already solves as part of its layout pipeline, replacing continuous polling with a handful of well-timed callbacks. This guide covers the sentinel technique that underlies all of these effects, plus the specific configuration each variant needs.
Concept Framing
Every scroll-driven effect in this guide reduces to the same underlying question: has some geometric boundary been crossed? A sticky header asks whether the page has scrolled past the point where the header should switch from static to pinned. A reveal-on-scroll animation asks whether an element has entered the viewport far enough to start its transition. A scroll-spy nav asks which section currently occupies the reading position. None of these require a continuous stream of scroll coordinates — they require one notification at the moment the boundary changes.
The sentinel pattern makes this explicit. Instead of comparing window.scrollY against a header's offsetTop on every scroll tick, you place an invisible, zero-height marker element at the boundary you care about and let IntersectionObserver watch it. When the sentinel's intersection state flips, the browser tells you — once, asynchronously, batched with layout — rather than forcing your code to ask on every frame.
Reveal-on-scroll, scroll-spy, and progress indicators are all variations on this same sentinel idea, differing only in where the sentinel sits, how many sentinels are observed at once, and what rootMargin band is used to define "the moment that matters." Building a sticky header with IntersectionObserver walks through the pinned-header variant in full; scroll-spy navigation with IntersectionObserver covers the multi-section variant.
Spec / Signature Reference Table
| Configuration | Effect it enables | Typical value | Notes |
|---|---|---|---|
| Sentinel height | Pinned header trigger point | 0 (zero-height div) |
A real-height sentinel shifts the trigger by its own height; keep it at 0 unless you want a buffer |
rootMargin (single-boundary) |
Sticky header pin/unpin | "-1px 0px 0px 0px" |
Fires as soon as the sentinel's top edge crosses the viewport top |
rootMargin (centered band) |
Scroll-spy active section | "-40% 0px -40% 0px" |
Shrinks the effective root to a thin horizontal band near vertical center |
threshold (reveal animation) |
Reveal-on-scroll trigger | 0.15–0.25 |
Fires once enough of the element is visible to feel intentional, not edge-clipped |
threshold (progress bar) |
Reading-progress smoothness | dense array or computed from boundingClientRect |
Discrete thresholds step; computed ratios are smooth |
once / manual unobserve() |
Reveal animation cleanup | unobserve after first reveal | Prevents re-triggering the animation on scroll-up |
| Root | Scoped scroll containers | custom scrollable Element |
Required when the tracked content scrolls inside a panel, not the document |
Step-by-Step Implementation
Step 1 — Place a zero-height sentinel at the boundary
For a pinned header, the sentinel sits in the document flow immediately above the header markup. For a reveal animation, the sentinel is the element being revealed — no separate marker needed. For scroll-spy, each section itself acts as its own sentinel.
<div id="header-sentinel" style="height:0;"></div>
<header id="site-header">…</header>
Step 2 — Create one shared observer and register targets
interface ScrollBoundaryOptions {
rootMargin?: string;
threshold?: number | number[];
}
function observeBoundary(
target: Element,
onCross: (isPast: boolean) => void,
options: ScrollBoundaryOptions = {}
): IntersectionObserver {
const { rootMargin = '0px', threshold = 0 } = options;
const observer = new IntersectionObserver((entries: IntersectionObserverEntry[]) => {
const [entry] = entries;
onCross(!entry.isIntersecting);
}, { rootMargin, threshold });
observer.observe(target);
return observer;
}
Step 3 — Defer the class toggle to the next frame
Toggling a CSS class from inside the callback is cheap, but if the toggle also triggers a layout read elsewhere (measuring the header's new height for a spacer element, for example), queue that work in requestAnimationFrame rather than doing it synchronously in the observer callback:
const sentinel = document.getElementById('header-sentinel')!;
const header = document.getElementById('site-header')!;
observeBoundary(sentinel, (isPinned) => {
requestAnimationFrame(() => {
header.classList.toggle('is-pinned', isPinned);
});
}, { rootMargin: '-1px 0px 0px 0px' });
Step 4 — Respect prefers-reduced-motion in reveal effects
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const revealObserver = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
entry.target.classList.add(reduceMotion ? 'is-visible' : 'is-revealing');
revealObserver.unobserve(entry.target); // one-shot — do not re-trigger on scroll-up
}
}, { threshold: 0.2 });
document.querySelectorAll('[data-reveal]').forEach((el) => revealObserver.observe(el));
Step 5 — Disconnect on teardown
// React
useEffect(() => {
const observer = observeBoundary(sentinelRef.current!, setPinned, { rootMargin: '-1px 0px 0px 0px' });
return () => observer.disconnect();
}, []);
Configuration Variants
Each scroll-driven effect needs a different rootMargin / threshold shape because each is answering a slightly different geometric question.
| Effect | rootMargin shape | threshold | Why |
|---|---|---|---|
| Sticky header pin | Single top edge, e.g. -1px 0 0 0 |
0 |
Only the moment the sentinel's top leaves the viewport top matters |
| Reveal-on-scroll | Bottom-biased, e.g. 0px 0px -10% 0px |
0.15–0.3 |
Delays the trigger until the element is meaningfully inside the viewport, not just clipping the edge |
| Scroll-spy nav | Centered band, e.g. -40% 0px -40% 0px |
0 |
Only sections crossing the reading position (vertical center) register |
| Reading-progress bar | 0px (full viewport) |
computed from boundingClientRect, not threshold array |
A discrete threshold array steps visually; continuous math from entry geometry is smooth |
| Pinned header with shadow-on-scroll | Two sentinels: one at 0, one at ~4px scroll | 0 each |
Separates "pinned" state from "scrolled enough to show a shadow" state |
position: sticky and IntersectionObserver are complementary, not competing. Let CSS position: sticky do the actual pinning — the compositor thread handles it without any JavaScript involvement — and use the observer purely to detect when the sticky element has become pinned, so you can add a shadow, compact the layout, or swap a logo variant at that exact moment:
#site-header {
position: sticky;
top: 0;
transition: box-shadow 0.15s ease;
}
#site-header.is-pinned {
box-shadow: 0 2px 8px rgb(0 0 0 / 0.12);
}
// Detect the moment `position: sticky` engages, without polling getBoundingClientRect()
const stickySentinel = document.createElement('div');
stickySentinel.style.cssText = 'position:absolute;top:0;height:1px;width:1px;';
header.parentElement!.insertBefore(stickySentinel, header);
new IntersectionObserver(([entry]) => {
header.classList.toggle('is-pinned', !entry.isIntersecting);
}, { threshold: 0 }).observe(stickySentinel);
Edge Cases & Gotchas
Sticky headers inside scrollable containers, not the document. If the header lives inside a scrollable <div> rather than the page body, the observer's root must be set to that container explicitly — the default root: null only tracks the top-level document viewport, and the sentinel will never appear to leave a container it is not actually scrolling within.
Rapid scroll produces coalesced crossings. On a fast trackpad fling, the sentinel may cross the boundary and come back before the browser delivers a callback, or several crossings may coalesce into one notification. Always read the current isIntersecting value from the delivered entry rather than assuming exactly one callback per physical crossing.
Reveal animations firing on page load for above-the-fold content. Because observe() always delivers one synchronous-feeling notification immediately, elements already visible at load will report isIntersecting: true right away and play their entrance animation on load rather than on scroll. This is usually desirable, but if you want above-the-fold elements to render already-visible with no animation, check entry.boundingClientRect.top against 0 before the first paint and mark those elements as pre-revealed synchronously.
Scroll-spy with sections shorter than the rootMargin band. If a section's height is smaller than the centered band created by a -40% top/bottom rootMargin, the section may never fully enter the band and will be skipped by nav highlighting. Fall back to intersectionRatio comparison across all currently intersecting entries rather than assuming exactly one section is ever "the" active one.
Layout thrash from measuring header height on every pin/unpin. If your layout reserves space for a header using a spacer element sized to the header's offsetHeight, do not re-measure that height inside the observer callback — cache it once on load and only re-measure on a debounced ResizeObserver callback for the header itself, since IntersectionObserver alone does not report size changes.
iOS Safari address bar transitions. The dynamic show/hide of the mobile browser chrome changes the visual viewport height mid-scroll, which can produce a spurious sentinel crossing unrelated to actual page scroll. Debounce class toggles by a single animation frame so a transient chrome-driven flicker does not visibly double-toggle the pinned state.
Framework Integration Patterns
React — pinned-header hook
import { useEffect, useRef, useState } from 'react';
function useHeaderPin(rootMargin = '-1px 0px 0px 0px'): [React.RefObject<HTMLDivElement>, boolean] {
const sentinelRef = useRef<HTMLDivElement>(null);
const [isPinned, setPinned] = useState(false);
useEffect(() => {
if (!sentinelRef.current) return;
const observer = new IntersectionObserver(([entry]) => {
setPinned(!entry.isIntersecting);
}, { rootMargin });
observer.observe(sentinelRef.current);
return () => observer.disconnect();
}, [rootMargin]);
return [sentinelRef, isPinned];
}
Vue 3 — reveal-on-scroll directive
import { onMounted, onUnmounted, ref } from 'vue';
export function useRevealOnScroll(threshold = 0.2) {
const el = ref<HTMLElement | null>(null);
const revealed = ref(false);
let observer: IntersectionObserver | null = null;
onMounted(() => {
if (!el.value) return;
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
observer = new IntersectionObserver(([entry]) => {
if (!entry.isIntersecting) return;
revealed.value = true;
observer?.unobserve(entry.target);
if (reduceMotion) el.value?.classList.add('is-visible');
}, { threshold });
observer.observe(el.value);
});
onUnmounted(() => observer?.disconnect());
return { el, revealed };
}
Angular — sticky header directive
import { Directive, ElementRef, HostBinding, OnDestroy, OnInit } from '@angular/core';
@Directive({ selector: '[appStickyHeader]', standalone: true })
export class StickyHeaderDirective implements OnInit, OnDestroy {
@HostBinding('class.is-pinned') isPinned = false;
private observer!: IntersectionObserver;
private sentinel!: HTMLDivElement;
constructor(private el: ElementRef<HTMLElement>) {}
ngOnInit(): void {
this.sentinel = document.createElement('div');
this.sentinel.style.cssText = 'height:0;';
this.el.nativeElement.parentElement?.insertBefore(this.sentinel, this.el.nativeElement);
this.observer = new IntersectionObserver(
([entry]) => { this.isPinned = !entry.isIntersecting; },
{ rootMargin: '-1px 0px 0px 0px' }
);
this.observer.observe(this.sentinel);
}
ngOnDestroy(): void {
this.observer.disconnect();
this.sentinel.remove();
}
}
Debugging Checklist
Header never pins:
- Confirm the sentinel actually sits before the header in DOM order and has
height: 0rather than being accidentally hidden withdisplay: none(which reports no intersection changes at all). - Check that no ancestor has
overflow: hiddenclipping the sentinel out of the layout entirely. - If the header lives in a scrollable panel, verify
rootis set to that panel, not left as the default document viewport.
Nav highlight jumps to the wrong section:
- Log every entry in the callback, not just the first — multiple sections are frequently intersecting simultaneously near a centered
rootMarginband. Resolve ties by comparingintersectionRatioorboundingClientRect.topdistance from center. - Verify
rootMarginpercentages resolve against the viewport as expected; a band that is too wide (e.g.-10%) will report several sections as active at once.
Reveal animation replays on scroll-up:
- Confirm
unobserve()is called immediately after the firstisIntersecting: trueentry. Without it, scrolling the element back into view retriggers the animation class every time.
Reproduction script:
// Paste in DevTools console to watch sentinel crossings live
const sentinel = document.querySelector('#header-sentinel');
new IntersectionObserver((entries) => {
entries.forEach((e) => console.log('isIntersecting:', e.isIntersecting, 'top:', e.boundingClientRect.top.toFixed(1)));
}, { rootMargin: '-1px 0px 0px 0px' }).observe(sentinel);
For deeper timing analysis of how these callbacks line up with paint, see syncing observer callbacks with requestAnimationFrame; for the underlying threshold mechanics used in the reveal and progress variants, see how IntersectionObserver threshold works in practice.
FAQ
Why use a sentinel element instead of checking scroll position directly?
A sentinel converts a continuous scroll-position comparison into a single discrete boundary event. IntersectionObserver already tracks the sentinel's geometry as part of the browser's layout pipeline, so you get one callback exactly when the boundary is crossed instead of polling scrollY on every frame and computing the comparison yourself.
Can I use position: sticky and IntersectionObserver together?
Yes, and it is the recommended combination. Let CSS position: sticky handle the actual pinning behavior, which the compositor manages without JavaScript. Use IntersectionObserver only to detect the moment the sticky element becomes pinned, so you can toggle a class for shadows, background color changes, or compact layout variants at that exact boundary.
How do I highlight the correct nav item when multiple sections are visible at once?
Use a narrow rootMargin band, such as -40% top and -40% bottom, so only sections crossing the vertical center of the viewport register as intersecting. When more than one entry reports isIntersecting: true in the same callback, pick the one with the highest intersectionRatio or the smallest boundingClientRect.top rather than the first array index.
Do reveal-on-scroll animations need to respect prefers-reduced-motion?
Yes. Query window.matchMedia('(prefers-reduced-motion: reduce)') once at setup and branch your callback: apply the animation class for standard users, or apply the final visible state instantly with no transition for users who have requested reduced motion. This is an accessibility requirement, not an optional enhancement.
Why does my scroll progress indicator jump instead of updating smoothly?
A sparse threshold array only fires a handful of times per scroll pass, which is fine for discrete state toggles but produces visible steps in a continuous progress bar. For smooth progress indicators, compute the ratio from the sentinel or section boundingClientRect values you already receive in the callback instead of relying on a dense threshold array, and apply the resulting width or transform inside requestAnimationFrame.
Related
- Building a Sticky Header with IntersectionObserver
- Scroll-Spy Navigation with IntersectionObserver
- Dynamic Visibility Tracking
- IntersectionObserver API Deep Dive
- How IntersectionObserver threshold works in practice
↑ Back to Implementation Patterns for Viewport & Resize Tracking