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
npm install svg-scroll-draw
The simplest pin
Pin #panel at the top of the viewport for one full viewport-height of scroll:
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):
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:
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'),
});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:
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.
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
'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:
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).
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
| Option | Type | Description |
|---|---|---|
| pinDistance | number | Pixels of scroll to stay pinned. Default: window.innerHeight |
| top | number | Viewport Y (px) to pin at. Default: 0 (viewport top) |
| scrollContainer | string | Element | Custom scroll container. Default: window |
| onEnter | () => void | Fires when pin zone is entered (scrolling down) |
| onLeave | () => void | Fires when pin zone is exited at end (scrolling down) |
| onEnterBack | () => void | Fires when pin zone is re-entered (scrolling up) |
| onLeaveBack | () => void | Fires when pin zone is exited at start (scrolling up) |
| onProgress | (p: number) => void | Progress 0–1 through the pin zone, every frame |