I recently built a scroll-driven animation for a client site — coldwatersharp.ca, a knife sharpening service. The brief: a “5-point guarantee” section where each guarantee reveals one at a time as you scroll, with a rotating SVG blade graphic that steps through 5 positions (one per point) and policy text that cross-fades between items.
The first few approaches I considered were bad. Capturing the wheel event and calling preventDefault() fights the browser, breaks trackpad momentum, and is a mess on touch. IntersectionObserver can drive animations but doesn’t give you a smooth progress value — you get a ratio, not a step index, and it doesn’t compose well with “go to item N” navigation. What I wanted was a way to make the browser’s natural scroll do the work, without intercepting it.
The scroll sentinel pattern is the answer.
The Core Idea
Instead of capturing scroll events and doing something in response, you create a tall container — the “sentinel” — and let the browser scroll through it normally. The interactive section sits inside the sentinel as a sticky element, pinned to the viewport while the user naturally scrolls the sentinel’s height.
<!-- The sentinel: tall enough to give each item its own scroll range -->
<div class="scroll-sentinel">
<!-- This sticks to the top of the viewport for the full scroll distance -->
<div class="sticky-stage">
<!-- Your content here -->
</div>
</div>.scroll-sentinel {
height: 500vh; /* 5 items * 100vh each */
position: relative;
}
.sticky-stage {
position: sticky;
top: 0;
height: 100vh;
overflow: hidden;
}That’s the entire structural trick. The sentinel is 500vh tall. The stage is sticky at top: 0 with height: 100vh, so it pins in place for 400vh of scroll distance after it first hits the top. No JavaScript required to make it stick. No scroll lock. No preventDefault(). The browser handles it natively.
The Math
Once the sentinel is scrolling, you need to know where in the scroll range the user is, and which item that maps to. One scroll handler does it:
function getScrollIndex(sentinel, numItems) {
const scrolled = -sentinel.getBoundingClientRect().top;
const total = sentinel.offsetHeight - window.innerHeight;
const clamped = Math.max(0, Math.min(scrolled, total));
return Math.floor((clamped / total) * numItems);
}
window.addEventListener('scroll', () => {
const index = getScrollIndex(sentinel, 5);
updateUI(index);
}, { passive: true });getBoundingClientRect().top is negative once the sentinel has scrolled above the viewport, so negating it gives you pixels scrolled into the sentinel. Dividing by total (sentinel height minus one viewport height, since the last item should be fully visible when the sentinel ends) gives a 0—1 progress value. Multiply by numItems and floor it and you have an integer item index. Each item gets exactly total / numItems of scroll distance — about 80vh apiece here.
The handler is passive: true since we never call preventDefault(). This is the right contract with the browser: we’re observing, not intercepting.
What We Drove With It
SVG blade graphic. The graphic is a pentagon with 5 dots, one per guarantee point. Each step rotates it 72 degrees (360 / 5). The key CSS property is transform-box: view-box, which makes transform-origin: center reference the SVG’s own coordinate system rather than the element’s bounding box — essential for a non-square SVG to rotate around its visual center.
function updateBlade(index) {
const degrees = index * 72;
blade.style.transform = `rotate(${degrees}deg)`;
}CSS handles the transition:
.blade {
transform-box: view-box;
transform-origin: center;
transition: transform 0.4s cubic-bezier(0.4, 0, 0.2, 1);
}Text cross-fades. Each policy item is absolutely positioned in the same slot. The active item fades in at translateY(0), inactive items sit below at translateY(1rem) and opacity: 0. Updating index just toggles an .active class — CSS handles the rest.
Clickable nav dots. Five dots let you jump directly to any item. The trick is inverting the scroll math: given a target index, calculate what scroll position corresponds to it, then window.scrollTo() there.
dot.addEventListener('click', () => {
const total = sentinel.offsetHeight - window.innerHeight;
const targetScroll = sentinel.offsetTop + (index / numItems) * total;
window.scrollTo({ top: targetScroll, behavior: 'smooth' });
});No special state management needed — the scroll handler picks up naturally from wherever the scroll lands.
Why This Works Better
The pattern earns its keep for a few reasons:
It’s free on non-pointer input. Touch momentum, trackpad physics, keyboard scrolling, and Tab navigation all work without any special handling. The browser’s scroll engine drives everything; our code just reads the position.
Reversibility is trivial. If you want to remove the effect entirely, you pull out the sentinel wrapper and the scroll handler. The underlying content returns to normal document flow with no other changes.
CSS does the heavy lifting. Because we’re only computing an integer index and applying a class, the animation logic lives in CSS where it belongs: hardware-accelerated, transition-aware, and easy to tune without touching JavaScript.
One Gotcha
The sentinel adds real height to the page. A 500vh div is five screen-lengths of scroll distance that didn’t exist before, and that affects everything that measures page height: anchor links will miss their targets if the sentinel sits above them, analytics scroll-depth tracking will report differently, and anything that calculates document.body.scrollHeight will see a much taller page.
In practice this is easy to manage — just make sure the sentinel is the last significant section, or account for the offset in any anchor calculations. But it’s worth knowing upfront, especially if you’re retrofitting this into a page with existing anchor navigation.
The sentinel pattern is one of those approaches that feels obvious in retrospect: let the browser scroll, then read the position. No interception, no special cases, no fighting the platform. A tall div and 15 lines of math, and the browser does the rest.