How-ToJune 2026 · 7 min read

Pin sections on scroll
without GSAP.

The Apple product page. The Stripe feature walkthrough. The sticky image with scrolling text. These all use the same pattern: pin an element in place while the page scrolls past it. Here's how to do it without GSAP — in ~9 KB total.

What “pin” means

A pinned element is fixed to a viewport position while the page scrolls a defined distance past it. When the scroll reaches the end of the “pin zone”, the element is released and continues with the page.

GSAP ScrollTrigger built the mental model: pin: true + a scroll distance. svg-scroll-draw ships the same pattern as scrollPin in the svg-scroll-draw/pin sub-path.

Install

terminal
npm install svg-scroll-draw

The simplest pin

Pin #panel at the top of the viewport for one full viewport-height of scroll:

app.js
import { scrollPin } from 'svg-scroll-draw/pin';

const instance = scrollPin('#panel', {
  pinDistance: window.innerHeight, // default
});

// Later: remove pin and restore the DOM
// instance.destroy();

That's it. scrollPinwraps the target in a spacer div (so the page layout doesn't jump), applies position: fixed when the element hits the viewport top, and releases it when the pin zone ends.

Pin at a custom viewport position

Use the top option to pin at a different viewport Y offset — e.g. 80px below the top (for a fixed navbar):

app.js
scrollPin('#sticky-image', {
  top:         80,           // pin 80px from viewport top
  pinDistance: 600,          // hold for 600px of scroll
});

Lifecycle callbacks

Four callbacks mirror GSAP's ScrollTrigger API exactly:

app.js
scrollPin('#feature-panel', {
  pinDistance: 800,
  onEnter:     () => panel.classList.add('active'),
  onLeave:     () => panel.classList.remove('active'),
  onEnterBack: () => panel.classList.add('active'),
  onLeaveBack: () => panel.classList.remove('active'),
});
onEnter fires when scroll first reaches the pin zone (scrolling down).
onLeave fires when scroll exits the pin zone at the end (scrolling down).
onEnterBack fires when scroll re-enters the pin zone (scrolling back up).
onLeaveBack fires when scroll exits the pin zone at the start (scrolling back up).

Progress callback

Use onProgress to drive another animation while the element is pinned:

app.js
import { scrollPin }    from 'svg-scroll-draw/pin';
import { scrollAnimate } from 'svg-scroll-draw';

// Fade in a caption as the user scrolls through the pin zone
scrollPin('#hero-image', {
  pinDistance: 600,
  onProgress: (p) => {
    // p is 0 → 1 through the pin zone
    caption.style.opacity = p.toFixed(3);
  },
});

Apple-style: pin image, scroll text

The classic pattern: image stays fixed while feature copy scrolls past it.

feature-section.js
import { scrollPin }    from 'svg-scroll-draw/pin';
import { scrollAnimate } from 'svg-scroll-draw';

// Pin the product image
const pin = scrollPin('#product-image', {
  top:         80,
  pinDistance: window.innerHeight * 3,
  onEnter: () => image.src = '/hero-active.png',
});

// Animate each feature block as it scrolls in
document.querySelectorAll('.feature-block')
  .forEach(el => {
    scrollAnimate(el, {
      props: {
        opacity:   [0, 1],
        transform: ['translateY(32px)', 'translateY(0)'],
      },
      easing: 'ease-out',
      once:   true,
    });
  });

React usage

FeatureSection.tsx
'use client';
import { useEffect, useRef } from 'react';
import { scrollPin } from 'svg-scroll-draw/pin';

export function FeatureSection() {
  const imageRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    if (!imageRef.current) return;
    const instance = scrollPin(imageRef.current, {
      top:         80,
      pinDistance: window.innerHeight * 2,
      onEnter:     () => console.log('pinned'),
      onLeave:     () => console.log('released'),
    });
    return () => instance.destroy();
  }, []);

  return (
    <section className="flex gap-16">
      <div ref={imageRef} className="w-1/2">
        <img src="/product.png" alt="Product" />
      </div>
      <div className="w-1/2 space-y-64 py-32">
        <p>Feature one text...</p>
        <p>Feature two text...</p>
        <p>Feature three text...</p>
      </div>
    </section>
  );
}

Section snapping

Often pin sections are combined with scroll snap. svg-scroll-draw ships both:

app.js
import { scrollPin }  from 'svg-scroll-draw/pin';
import { scrollSnap } from 'svg-scroll-draw/snap';

// Snap between fullscreen sections
scrollSnap('.section', {
  duration:  600,
  easing:    'ease-in-out',
  onSnap:    (index) => console.log('section', index),
});

// Pin the nav while scrolling
scrollPin('#nav', {
  top:         0,
  pinDistance: document.body.scrollHeight,
});

Cleanup

Every scrollPin call returns an instance with destroy() and refresh(). Call destroy() in component cleanup / route change. Call refresh() if the page layout changes dynamically (accordion opens, dynamic content loads).

app.js
const pin = scrollPin('#panel', { pinDistance: 600 });

// Recalculate after layout change
document.querySelector('#accordion').addEventListener('click', () => {
  pin.refresh();
});

// Remove on page unload
window.addEventListener('beforeunload', () => pin.destroy());

Full API

OptionTypeDescription
pinDistancenumberPixels of scroll to stay pinned. Default: window.innerHeight
topnumberViewport Y (px) to pin at. Default: 0 (viewport top)
scrollContainerstring | ElementCustom scroll container. Default: window
onEnter() => voidFires when pin zone is entered (scrolling down)
onLeave() => voidFires when pin zone is exited at end (scrolling down)
onEnterBack() => voidFires when pin zone is re-entered (scrolling up)
onLeaveBack() => voidFires when pin zone is exited at start (scrolling up)
onProgress(p: number) => voidProgress 0–1 through the pin zone, every frame