Place a zero-height sentinel immediately before the sticky element, observe it with a rootMargin whose top equals the negative of the sticky top offset, and toggle a stuck class when the sentinel leaves the top of the root — the sticky element is stuck exactly while its sentinel is scrolled past.

Problem / Scenario Context

A long settings page has section headers with position: sticky; top: 0. The design adds a shadow and shrinks the header's padding while a header is stuck, so users can tell it is floating over content. CSS alone cannot select "a sticky element that is currently stuck", and the team's first attempt compares header.getBoundingClientRect().top with 0 in a scroll listener — which works but runs on every scroll event for every header, and misfires for the last section, whose header is pushed up by the end of its container rather than stuck.

Stuck detection is a visibility question about the point just above the sticky element. The Scroll-Driven Effects & Sticky Headers topic introduces sticky patterns; this page builds the detector.

Mechanics Explanation

A position: sticky; top: T element behaves like a normal in-flow element until its top edge would scroll above T pixels from the top of its scrollport; then it stays pinned at T until the bottom of its containing block pushes it away.

A sentinel element placed directly before the sticky element in the flow does not stick. It scrolls normally, and the moment the sticky element becomes stuck is the moment the sentinel scrolls past the line T pixels below the scrollport's top. So:

  • Observe the sentinel with the sticky element's scroll container as root (or the viewport for page-level stickies).
  • Shrink the root's top by T with rootMargin: '-Tpx 0px 0px 0px'.
  • The sentinel is not intersecting and its boundingClientRect.top is above the root → the sticky element is stuck.
  • The sentinel is intersecting, or not intersecting because it is below the root → not stuck.

When the section ends, the containing block pushes the sticky element up and out; it is no longer stuck in the visual sense even though the sentinel is still above. A second sentinel at the end of the section, observed the same way, detects that release.

Sentinel Above a Sticky HeaderA scroll container with a sticky header pinned at its top. The zero-height sentinel that sits directly above the header in the flow has scrolled above the top edge of the root, so the header is reported as stuck. Further down, the next section's sentinel is still inside the root, so that section's header is not stuck.sentinel A — scrolled above, header A is stuckheader A — pinned at top: 0sentinel B — still inside the rootheader B — in flow, not stuckSolid blue frame: viewport (root).

Comparison Table: Ways to Detect Stuck State

Approach Work while scrolling Handles end-of-section release Scroll containers Support
Scroll listener + getBoundingClientRect every event with extra checks yes everywhere
Observe the sticky element with threshold: 1 and top: -1px none partially yes everywhere; brittle
Sentinel before the sticky element none needs a bottom sentinel yes everywhere
CSS @container scroll-state(stuck: top) none yes yes Chromium, newer

The threshold: 1 trick — top: -1px so a stuck element is 1 px out of view and its ratio drops below 1 — is widely shared but fragile: it depends on exact sub-pixel geometry, visually shifts the element by a pixel, and cannot tell "stuck" from "partly scrolled off at the bottom".

Minimal Reproducible Example

TypeScript
const headers = document.querySelectorAll<HTMLElement>('.section-header');
addEventListener('scroll', () => {
  headers.forEach((h) => h.classList.toggle('stuck', h.getBoundingClientRect().top <= 0));
}, { passive: true });

The last section's header gets stuck while it is being pushed off-screen by the end of its section, and every scroll event reads layout for every header.

Production-Safe Solution

HTML
<section class="settings-section">
  <div class="sticky-sentinel sticky-sentinel--top" aria-hidden="true"></div>
  <h2 class="section-header">Notifications</h2>
  <!-- section content -->
  <div class="sticky-sentinel sticky-sentinel--bottom" aria-hidden="true"></div>
</section>
CSS
.section-header { position: sticky; top: 0; transition: padding 150ms, box-shadow 150ms; }
.section-header.stuck { padding-block: 0.25rem; box-shadow: 0 2px 6px rgb(0 0 0 / 0.15); }
.sticky-sentinel { block-size: 0; }
.sticky-sentinel--bottom { margin-block-start: -3rem; }   /* ≈ header height: release as it starts leaving */
@media (prefers-reduced-motion: reduce) { .section-header { transition: none; } }
TypeScript
export function watchStuck(root: HTMLElement | null, stickyTop = 0): () => void {
  const opts: IntersectionObserverInit = { root, rootMargin: `-${stickyTop}px 0px 0px 0px`, threshold: 0 };

  const headerOf = (sentinel: Element) =>
    sentinel.parentElement!.querySelector<HTMLElement>('.section-header')!;

  const top = new IntersectionObserver((entries) => {
    for (const e of entries) {
      const rootTop = e.rootBounds?.top ?? 0;
      const above = e.boundingClientRect.top < rootTop;
      headerOf(e.target).classList.toggle('stuck', !e.isIntersecting && above);
    }
  }, opts);

  const bottom = new IntersectionObserver((entries) => {
    for (const e of entries) {
      const rootTop = e.rootBounds?.top ?? 0;
      // Bottom sentinel passing above the top line means the section is ending: release.
      if (!e.isIntersecting && e.boundingClientRect.top < rootTop) headerOf(e.target).classList.remove('stuck');
      // Scrolling back up into the section: stuck again if the top sentinel is still above.
      else if (e.isIntersecting) {
        const topSentinel = e.target.parentElement!.querySelector('.sticky-sentinel--top')!;
        const r = topSentinel.getBoundingClientRect();              // rare: only on re-entry
        if (r.top < rootTop) headerOf(e.target).classList.add('stuck');
      }
    }
  }, opts);

  document.querySelectorAll('.sticky-sentinel--top').forEach((s) => top.observe(s));
  document.querySelectorAll('.sticky-sentinel--bottom').forEach((s) => bottom.observe(s));
  return () => { top.disconnect(); bottom.disconnect(); };
}

Observers run only when a sentinel crosses the line, so scrolling through the middle of a section costs nothing. The class changes only padding and shadow; because the sentinel sits before the header, the header's own size change does not move the sentinel and cannot feed back into the observation. The -3rem margin on the bottom sentinel releases the header as it begins to be pushed out, so the shadow does not linger on a header that is sliding away.

A Header's Stuck LifecycleFour steps. Scrolling down, the top sentinel crosses above the root's top line and the header becomes stuck. Scrolling through the section changes nothing. Near the end of the section, the bottom sentinel crosses above the line and the header is released as it is pushed out. Scrolling back up, the bottom sentinel re-enters and the header is marked stuck again if the top sentinel is still above.1Top sentinel passes aboveHeader becomes stuck: shadow and compact padding.2Scroll through the sectionNo crossings, no callbacks, no work.3Bottom sentinel passesaboveHeader released as the section ends.4Scroll back upBottom sentinel re-enters: stuck again if the top one is above.

The CSS Future: scroll-state Container Queries

Chromium has shipped scroll-state container queries, which let CSS style descendants of a sticky element based on whether it is stuck:

CSS
.section-header { position: sticky; top: 0; container-type: scroll-state; }
@container scroll-state(stuck: top) {
  .section-header > .inner { padding-block: 0.25rem; box-shadow: 0 2px 6px rgb(0 0 0 / 0.15); }
}

Where supported, this removes the script entirely for styling. Two limits keep the sentinel technique relevant: other engines do not yet support it, and CSS cannot run code — if the stuck state must update an aria attribute, analytics or a JavaScript-driven component, you still need to observe. Feature-detect with CSS.supports('container-type', 'scroll-state') and fall back to the observer.

Sentinel Observer Versus scroll-state QueriesTwo columns. The sentinel observer works in every browser, can run JavaScript when the state changes, and needs extra markup. Scroll-state container queries need no script or markup, style descendants of the sticky element directly, are currently Chromium-only and cannot trigger JavaScript.Sentinel + IntersectionObserverWorks in every engineCan run code: ARIA, analytics, componentsNeeds sentinel markup@container scroll-state(stuck)No script, no extra markupStyles descendants of the sticky elementChromium only for now; styling only

Verification Steps

  • Scroll slowly through several sections and confirm each header gains and loses the stuck style exactly as it pins and unpins.
  • Check the last section — its header should lose the shadow as it is pushed out.
  • Scroll back up through a section and confirm it re-sticks correctly.
  • Use a non-zero top (for example below a global app bar) and pass it as stickyTop.
  • Record a trace while scrolling and confirm no callbacks run mid-section.

Common Mistakes to Avoid

  • Observing the sticky element itself. Its position is what changes; observe a sentinel that does not stick.
  • Forgetting the sticky top offset in rootMargin.
  • Ignoring the end of the containing block. Headers are pushed out, not stuck, at the end of their section.
  • Changing the header's height in a way that moves the sentinel. Keep the sentinel before the header so it is unaffected.

FAQ

Is there a CSS selector for stuck sticky elements?

Not a selector, but Chromium supports scroll-state container queries with stuck conditions, which style descendants of a sticky container. Other engines need the sentinel technique.

Why not observe the sticky header with threshold 1?

That trick requires offsetting the header by a pixel and relies on exact sub-pixel geometry. It also confuses being stuck with being partly scrolled off-screen. A sentinel is simpler and more robust.

Does the sentinel need any height?

No. A zero-height element still crosses the root's edge at a precise position, which is exactly the point where the sticky element becomes stuck.

What root should I use for sticky elements in a scroll container?

The scroll container itself, because sticky positioning is relative to the nearest scrollport. Using the viewport would detect the wrong line.

Can one observer handle all headers on the page?

Yes. One observer for top sentinels and one for bottom sentinels, each observing every section's sentinel, is all you need regardless of the number of sections.

Should the stuck state be announced to screen readers?

No. It is a visual affordance. The header's text and its heading level carry the meaning; announcing stickiness would be noise.


↑ Back to Scroll-Driven Effects & Sticky Headers