---
title: "Ultimate Guide: CSS Scroll Pinning with React Progress"
slug: "css-scroll-pinning-react-progress"
published: "2026-05-28"
updated: "2026-06-02"
categories:
  - "Next.js"
tags:
  - "CSS scroll pinning"
  - "position: sticky"
  - "React progress bar"
  - "StickyScrollWrapper"
  - "Tailwind CSS"
  - "Next.js"
  - "scroll progress"
  - "getBoundingClientRect"
  - "passive scroll listener"
  - "GSAP alternative"
  - "viewport height mobile"
  - "pin duration tuning"
llm-intent: "reference"
audience-level: "intermediate"
framework-versions:
  - "react"
  - "next.js"
  - "tailwind css"
  - "typescript"
  - "browser dom api"
status: "stable"
llm-purpose: "CSS scroll pinning: implement position:sticky with a single React ref to create a performant pinned card and live progress bar in Next.js. Try it now."
llm-prereqs:
  - "Access to React"
  - "Access to Next.js"
  - "Access to Tailwind CSS"
  - "Access to TypeScript"
  - "Access to Browser DOM API"
llm-outputs:
  - "Completed outcome: CSS scroll pinning: implement position:sticky with a single React ref to create a performant pinned card and live progress bar in Next.js. Try it now."
---

**Summary Triples**
- (Pinning technique, uses, CSS position:sticky on an inner card with a taller parent 'scroll track' container)
- (React integration, requires, a single ref to the scroll track to compute progress and pin duration)
- (Progress calculation, uses, getBoundingClientRect() on the track to compute a 0–100 progress value)
- (Scroll listener, is, a single passive scroll listener that updates an integer progress state)
- (Render strategy, limits, state updates to an integer capped at 100 to avoid excessive re-renders (max 100 per pin))
- (Pin duration, controlled by, the height of the scroll track (taller track = longer pin))
- (Mobile viewport issues, avoided by, letting the compositor handle pinning (CSS) instead of JS-based pinning (e.g., ScrollTrigger))
- (Bundle size, reduced by, avoiding GSAP/ScrollTrigger and using native CSS (saves ~45kb))
- (Performance best practice, includes, using passive listeners and reading bounding rect (no layout writes during scroll handler))
- (Tuning, requires, adjusting track height and sticky top offsets for responsive breakpoints)

### {GOAL}
CSS scroll pinning: implement position:sticky with a single React ref to create a performant pinned card and live progress bar in Next.js. Try it now.

### {PREREQS}
- Access to React
- Access to Next.js
- Access to Tailwind CSS
- Access to TypeScript
- Access to Browser DOM API

### {STEPS}
1. Create StickyScrollWrapper component
2. Render the thin sticky progress bar
3. Build the pinned card layout
4. Compose into a Next.js page
5. Tune duration and accessibility

<!-- llm:goal="CSS scroll pinning: implement position:sticky with a single React ref to create a performant pinned card and live progress bar in Next.js. Try it now." -->
<!-- llm:prereq="Access to React" -->
<!-- llm:prereq="Access to Next.js" -->
<!-- llm:prereq="Access to Tailwind CSS" -->
<!-- llm:prereq="Access to TypeScript" -->
<!-- llm:prereq="Access to Browser DOM API" -->
<!-- llm:output="Completed outcome: CSS scroll pinning: implement position:sticky with a single React ref to create a performant pinned card and live progress bar in Next.js. Try it now." -->

# Ultimate Guide: CSS Scroll Pinning with React Progress
> CSS scroll pinning: implement position:sticky with a single React ref to create a performant pinned card and live progress bar in Next.js. Try it now.
Matija Žiberna · 2026-05-28

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.

```
Parent Track (relative, h-[220vh])
└── Pinned Child (sticky top-16, h-screen)
    ├── Header (fixed height, flex-shrink-0)
    ├── Scrollable Options (flex-grow, min-h-0, overflow-y-auto)
    └── Footer (fixed height, flex-shrink-0)
```

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.

```tsx
// File: src/components/remodel/sticky-scroll-wrapper.tsx
'use client'

import { useEffect, useRef, useState } from 'react'

interface StickyScrollWrapperProps {
  children: React.ReactNode
}

export function StickyScrollWrapper({ children }: StickyScrollWrapperProps) {
  const containerRef = useRef<HTMLDivElement>(null)
  const [progressPct, setProgressPct] = useState(0)

  useEffect(() => {
    const handleScroll = () => {
      if (!containerRef.current) return

      const rect = containerRef.current.getBoundingClientRect()
      const containerHeight = rect.height
      const viewHeight = window.innerHeight

      const scrolled = -rect.top
      const scrollable = containerHeight - viewHeight

      if (scrollable <= 0) return

      const currentProgress = Math.max(0, Math.min(1, scrolled / scrollable))
      const pct = Math.round(currentProgress * 100)

      setProgressPct((prev) => (prev !== pct ? pct : prev))
    }

    window.addEventListener('scroll', handleScroll, { passive: true })
    handleScroll()

    return () => window.removeEventListener('scroll', handleScroll)
  }, [])

  return (
    <div ref={containerRef} className="relative lg:h-[220vh]">
      <div className="sticky top-16 left-0 right-0 z-50 h-[3px] bg-border/20 pointer-events-none hidden lg:block">
        <div
          className="h-full bg-primary"
          style={{ width: `${progressPct}%` }}
        />
      </div>

      {children}
    </div>
  )
}
```

A few things worth understanding here.

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
// File: src/components/remodel/quiz-step-layout.tsx
interface QuizStepLayoutProps {
  children: React.ReactNode
}

export function QuizStepLayout({ children }: QuizStepLayoutProps) {
  return (
    <div className="flex flex-col h-full justify-between min-h-0">
      {/* Header: locked at top, never shrinks */}
      <div className="space-y-3 mb-6 flex-shrink-0">
        <ProgressBar />
      </div>

      {/* Middle: grows to fill available height, scrolls internally when needed */}
      <div className="flex-grow min-h-0 overflow-y-auto pr-1">
        {children}
      </div>

      {/* Footer: locked at bottom, never shrinks */}
      <div className="flex items-center justify-between border-t border-border pt-5 mt-6 flex-shrink-0">
        <BackButton />
        <NextButton />
      </div>
    </div>
  )
}
```

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:

```tsx
// File: src/components/remodel/migration-quiz-card.tsx
<div className="border border-border bg-background p-6 md:p-8 flex flex-col lg:h-[62vh] lg:min-h-[520px] shadow-2xl">
  <MigrationQuizShell />
</div>
```

`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
// File: src/app/(frontend)/remodel/page.tsx
import { StickyScrollWrapper } from '@/components/remodel/sticky-scroll-wrapper'
import { SectionBlock } from '@/components/remodel/section-block'

export default function RemodelPage() {
  return (
    <main>
      <HeroSection />

      <StickyScrollWrapper>
        <SectionBlock
          id="quiz-section"
          className="border-t border-border lg:sticky lg:top-16 lg:h-[calc(100vh-4rem)] lg:flex lg:flex-col lg:justify-center lg:py-8 xl:py-10"
        >
          <div className="border border-border bg-background p-6 md:p-8 flex flex-col lg:h-[62vh] lg:min-h-[520px] shadow-2xl">
            <QuizComponent />
          </div>
        </SectionBlock>
      </StickyScrollWrapper>

      <NextSection />
    </main>
  )
}
```

`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

| Feature | GSAP ScrollTrigger `pin: true` | Native CSS + React |
|---|---|---|
| Performance | JS thread recalculates layout on scroll | Compositor thread — no JS involvement |
| Bundle size | ~45kb (GSAP + ScrollTrigger) | 0kb additional |
| Mobile scroll jitter | Can conflict with dynamic URL bars | Native browser scrolling, no jitter |
| Offset positioning | Computed in pixels via JS | Standard CSS values (`lg:top-16`) |
| Responsive handling | Requires `ScrollTrigger.matchMedia()` | Standard Tailwind responsive prefixes |
| Maintenance surface | External API to keep updated | Zero dependencies |

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.

Thanks,
Matija

## LLM Response Snippet
```json
{
  "goal": "CSS scroll pinning: implement position:sticky with a single React ref to create a performant pinned card and live progress bar in Next.js. Try it now.",
  "responses": [
    {
      "question": "What does the article \"Ultimate Guide: CSS Scroll Pinning with React Progress\" cover?",
      "answer": "CSS scroll pinning: implement position:sticky with a single React ref to create a performant pinned card and live progress bar in Next.js. Try it now."
    }
  ]
}
```