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.

Sentinel-Above-Header Technique for Pinning a Sticky Header Two side-by-side viewport diagrams. In the left state, a zero-height sentinel element above the header is inside the viewport, isIntersecting is true, and the header sits in normal document flow. In the right state, the user has scrolled down, the sentinel has moved above the top of the viewport, isIntersecting is false, and the header switches to a fixed, pinned state with a shadow. An arrow labeled user scrolls down connects the two states. State A — sentinel in viewport sentinel (0px tall) — isIntersecting: true Header (static) State B — sentinel scrolled past sentinel above viewport — isIntersecting: false Header .is-pinned position: fixed + shadow user scrolls down scrolls back up Toggling .is-pinned on the isIntersecting boundary avoids reading layout geometry 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.150.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.

HTML
<div id="header-sentinel" style="height:0;"></div>
<header id="site-header"></header>

Step 2 — Create one shared observer and register targets

TypeScript
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:

TypeScript
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

TypeScript
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

TypeScript
// 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.150.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:

CSS
#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);
}
TypeScript
// 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

TypeScript
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

TypeScript
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

TypeScript
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: 0 rather than being accidentally hidden with display: none (which reports no intersection changes at all).
  • Check that no ancestor has overflow: hidden clipping the sentinel out of the layout entirely.
  • If the header lives in a scrollable panel, verify root is 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 rootMargin band. Resolve ties by comparing intersectionRatio or boundingClientRect.top distance from center.
  • Verify rootMargin percentages 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 first isIntersecting: true entry. Without it, scrolling the element back into view retriggers the animation class every time.

Reproduction script:

JavaScript
// 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.


↑ Back to Implementation Patterns for Viewport & Resize Tracking