Ultimate Guide: CSS Scroll Pinning with React Progress
Ultimate Guide: CSS Scroll Pinning with React Progress
Build native CSS scroll pinning with position:sticky, a single React ref, and a lightweight progress bar - no GSAP.
·Updated on:··
⚡ Next.js Implementation Guides
In-depth Next.js guides covering App Router, RSC, ISR, and deployment. Get code examples, optimization checklists, and prompts to accelerate development.
You can implement scroll-pinning with a live progress bar using only CSS position: sticky, a single React ref, and one scroll event listener. No GSAP. No ScrollTrigger. No 45kb of vendor bundle. The browser's compositor thread handles the pinning natively, and the React side tracks progress with a single integer state value capped at 100 re-renders per pin duration.
This guide walks through the complete implementation: the sticky scroll wrapper component, the internal card layout that survives viewport height constraints, and how to wire everything into a Next.js page.
Why I Stopped Reaching for GSAP
I was building a migration quiz section for a client — a Typeform-style multi-step wizard embedded mid-page, designed to stay pinned while the user reads through surrounding content. My first instinct was to reach for GSAP's ScrollTrigger with pin: true. I'd used it before. It works. Mostly.
The problem showed up on mobile. ScrollTrigger computes pin offsets in JavaScript on the scroll thread, which means it fights with dynamic viewport height changes from mobile browser URL bars. The card would bounce. The pin would briefly collapse. On slower Android devices, the recalculation lag was visible.
I also looked at the bundle: GSAP plus ScrollTrigger added roughly 45kb. For a pinning effect that CSS can handle natively, that felt wrong.
After an afternoon of testing, I landed on a pure CSS and React approach that performs better, ships zero extra bytes, and is straightforward to maintain.
The Core Concept: How CSS Pinning Works
CSS pinning relies on position: sticky, but sticky alone is not enough. You need two elements working together.
The scroll track is a parent container set to position: relative with a height taller than the viewport — something like h-[220vh]. That extra height is the pin duration. It determines how long the child stays locked before the page scrolls past it.
The pinned child sits inside the track and gets position: sticky with a top offset matching your navbar height. While the scrollbar moves through the extra height of the track, the child stays fixed to the viewport. When the track fully exits the viewport, the child unpins naturally and scrolls away with the rest of the page.
The browser handles all of this at the compositor layer. There are no JavaScript layout recalculations on scroll, and no fighting with mobile viewport height changes.
Step 1: The StickyScrollWrapper Component
This component wraps the sticky section, measures scroll progress through the parent track, and renders a thin progress bar just below the sticky navbar.
The outer div with ref={containerRef} and lg:h-[220vh] is the scroll track. The 220vh is tunable — increase it to give the pinned card more scroll real estate, decrease it if it resolves too quickly.
The progress calculation uses getBoundingClientRect() to find how far the container's top edge has traveled above the viewport top. When rect.top is 0, the container just entered the viewport. When rect.top equals -(containerHeight - viewHeight), the container's bottom edge is about to leave. Dividing the current scrolled distance by the total scrollable range gives a 0-to-1 progress value.
The key performance detail is in setProgressPct. The scroll event fires at 60fps or higher, but the progress percentage is rounded to an integer — so state only updates when the integer value actually changes. That caps re-renders at exactly 100 per pin duration instead of the ~1200 you'd get without it.
The { passive: true } flag tells the browser this listener will never call preventDefault(), allowing it to move scroll handling entirely off the main thread.
Step 2: Handling Viewport Height Constraints Inside the Pinned Card
A sticky child set to h-screen clips any content taller than the viewport. On multi-step wizards where step content varies in length, some steps will overflow and users won't be able to reach the bottom of the card.
The fix is to structure the card as a flex column with a constrained height, and let the middle content section grow and scroll independently.
tsx
{
: .
}
() {
(
)
}
The key CSS pattern here is flex-grow min-h-0 overflow-y-auto on the middle section. Without min-h-0, flexbox children will refuse to shrink below their intrinsic content height, which causes the overflow to never trigger. Setting min-h-0 removes that floor and lets overflow-y-auto take effect when content exceeds the available space.
The outer card wrapper applies a viewport-relative height to keep everything contained:
lg:h-[62vh] constrains the card to 62% of the viewport height on desktop. lg:min-h-[520px] prevents the card from collapsing on shorter desktop screens. Both values are tunable — adjust them until the card fits within the pinned container without overflow on your shortest supported screen height.
Step 3: Wiring Everything Into the Page
The wrapper and card compose cleanly at the page level:
tsx
{ }
{ }
() {
(
)
}
StickyScrollWrapper creates the 220vh scroll track and mounts the progress bar. The SectionBlock inside gets lg:sticky lg:top-16 — this is the actual pinned element that locks to the viewport. lg:h-[calc(100vh-4rem)] sizes it to fill the viewport minus the 4rem navbar height, so the card always has a centered, full-height container to work within.
On mobile, none of the sticky or scroll-track classes apply. The section renders as a normal block and the quiz scrolls with the page. The progress bar is also hidden on mobile with hidden lg:block — no sticky behavior, no progress tracking, no layout complexity.
Native CSS Sticky vs. GSAP ScrollTrigger
The tradeoff is animation capability. GSAP's ScrollTrigger pairs naturally with timeline-based animations — parallax, scrubbed transforms, staggered entrance effects. The native approach gives you the pinning behavior and nothing else. If your use case is a pinned UI component that tracks scroll progress, native CSS is the right tool. If you need scroll-driven animations with complex sequences, GSAP earns its bundle size.
FAQ
Can I adjust how long the card stays pinned?
Yes. The pin duration is controlled entirely by the lg:h-[220vh] class on the scroll track wrapper. Change 220vh to 300vh for a longer pin, or 160vh for a shorter one. One viewport height of extra space (100vh beyond the viewport itself) gives roughly one full scroll stroke on a typical trackpad.
Why does the progress bar disappear on mobile?
The hidden lg:block classes on the progress bar div hide it at mobile breakpoints. There is also no sticky behavior on mobile — the section renders as a normal block — so a progress bar tracking scroll through the pin zone would not map to anything meaningful.
What happens when the scroll track exits the viewport?
The sticky child unpins naturally. CSS position: sticky unpins automatically when the parent container scrolls out of view. The card scrolls away with the rest of the page, and the progress bar stays at 100% until it scrolls out of its own sticky position and disappears.
Can I use this with multiple pinned sections on the same page?
Yes. Each StickyScrollWrapper instance uses its own ref and its own scroll handler, so they track independently. Make sure each wrapper has a unique scroll track height that reflects the intended pin duration for that section.
Will the 100 re-render cap cause visible lag in the progress bar?
No. At 1% increments, each step is 1/100th of the scroll track height. On a 220vh track, that is 2.2vh per increment — imperceptible as a jump. The bar appears to animate continuously because the human eye cannot perceive 1% width changes as discrete steps.
Conclusion
Scroll-pinning a React component with a live progress bar does not require a JavaScript animation library. CSS position: sticky handles the layout locking at the compositor level, which performs better than anything on the JavaScript thread. A single useRef and a capped integer state value track progress without flooding React with unnecessary re-renders.
The pattern composes cleanly with Tailwind's responsive prefixes, so mobile gets a simple scrolling layout and desktop gets the full pinned experience — without a line of matchMedia configuration.
If you're already using this in a project or have a variation that solved a different edge case, let me know in the comments. Subscribe if you want more practical implementation guides like this one.