tool / scroll pinpin · steps · progress

Scroll pin generator

Pin a section while the page scrolls past it, and change what it shows as it holds: the feature tour. Set how long it stays pinned, how far down it pins and how many steps it shows, scroll the preview, and copy the HTML, CSS and JavaScript, a React component, or a version in CSS alone.

Options
8
States
3
scrollPin alone
1.6 KB
Library
2.23.3
scrollPin(stage, { top, pinDistance })wrapper · added by scrollPinstep 2fixedbefore · in flowafter · bottom 0windowheadertoppinDistanceprogress 0 · pin startsonEnter ↓ · onLeaveBack ↑step 1step 2 · progress 0.4step 3step 4progress 1 · pin endsonLeave ↓ · onEnterBack ↑wrapper = panel + pinDistance tallprogress = (top − wrapper’s top) ÷ pinDistance
Fig. 1 — scrollPin wraps the panel in a div as tall as the panel plus pinDistance. The panel is fixed under the pin line for that stretch of scroll, and onProgress reports it as 0 to 1: four steps, a quarter each.
01The tool

Build a pinned feature tour

The box below is a small window. Scroll it, drag the slider or press Play: the stage pins under the pin line, the steps change as you scroll through it, and it lets go at the end. The pinning is scrollPin itself, told to follow the box, and every number beside it is what the instance reports. Pick from 2 to 6 steps.

Steps while pinned
pinDistance · 75vh per step'300vh'
top · the pin line64Pins 64px down, under a header that tall.

Window · 340px tall · scroll it

Content before the pinscroll ↓
  1. 01 / 04

    Pin the stage

    It holds still while the rest of the page scrolls on.

  2. 02 / 04

    Split the scroll

    The pin distance is shared out: each step gets an equal part.

  3. 03 / 04

    Show one step

    onProgress says how far in you are, so one step shows at a time.

  4. 04 / 04

    Draw as you go

    Each line draws through its own share of the scroll.

Content after the pin
onProgress0.00
Step1 / 4

Before · In the flow, scrolling with the content.

Callbacks · newest first

None yet: scroll the box into the pin.

Window
340px tall: the box. Its vh are of it.
Wrapper
153px down the content, 276 + 1020 = 1296px tall
Pin start
153 − top 64 = 89px of scroll
Pin end
89 + pinDistance 1020 = 1109px
Progress
(0 − 89) ÷ 1020 = -0.09, clamped to 0
Step
⌊0.00 × 4⌋ + 1 = 1, capped at 4
tour.js26 lines
// Made with https://svg-scroll-draw.vercel.app/scroll-pin-generator
import { scrollPin } from 'svg-scroll-draw/pin';

const stage = document.querySelector('#tour-stage');
const steps = [...stage.querySelectorAll('.tour-step')];
let current = 0;

const pin = scrollPin(stage, {
  top: 64, // pins 64px down, under a 64px header
  pinDistance: '300vh', // 4 steps, 75vh of scroll each
  onProgress: (p) => {
    // p runs 0 → 1 through the pin; each step gets an equal share of it.
    const i = Math.min(steps.length - 1, Math.floor(p * steps.length));
    stage.style.setProperty('--progress', p);
    steps.forEach((step, j) => {
      step.style.setProperty('--draw', j < i ? 1 : j > i ? 0 : p * steps.length - i);
    });
    if (i !== current) {
      steps[current].removeAttribute('aria-current');
      steps[i].setAttribute('aria-current', 'step');
      current = i;
    }
  },
});

// pin.destroy() unpins it and puts the stage back where it was.

The HTML, CSS and JavaScript tabs are one tour. Options at scrollPin’s default are left out.

Run it
Recipe
For the same tour with a line drawing for each step, drawn by scrollDraw’s seek() from onProgress, see the feature tour recipe; for the walkthrough, pinning sections without GSAP.
028 options

Every scrollPin option

scrollPin(target, options) takes a selector or an element and returns an instance. Leave an option out and it takes the default; the generator leaves out every option at its default.

OptionTypeDefaultWhat it does
pinDistancenumber | '150vh' | '600px'window.innerHeightHow much scroll it stays pinned for. A number or a px string is pixels and stays that. A vh string is measured as CSS measures vh, from an element 100vh tall: it follows rotation and window resizes, but not a phone’s address bar. The default is read again on every resize, address bar included; anything else warns in development builds and uses the default.
topnumber0Where it pins, in px from the top of the window: the height of a fixed or sticky header. It is the window’s top even with a scrollContainer.
scrollContainerstring | ElementwindowWhose scroll moves the pin: a selector or an element, listened to instead of the window. Everything is still measured on screen (section 03).
onEnter() => void—Before → pinned: scrolling down into the pin.
onLeave() => void—Pinned → after: scrolling down out of its end.
onEnterBack() => void—After → pinned: scrolling back up into it.
onLeaveBack() => void—Pinned → before: scrolling back up out of its start.
onProgress(progress) => void—Progress through the pin, 0 to 1, on every update: each scroll frame, and after a resize or a change of layout. It is 0 before the pin and 1 after it, so it reports those too.
Instance
destroy() takes the listeners off, gives the element back its own inline styles and puts it back where the wrapper was. refresh() measures again. getProgress() returns the last progress. On the server, and for a selector that finds nothing, scrollPin returns an instance that does nothing.
Measured
The element’s height and the pin distance are measured when it is created, 150ms after the window resizes, and 100ms after the wrapper, the element or the page changes size, which a ResizeObserver watches for.
033 states

Before, pinned, after: what scrollPin writes

scrollPin puts the element in a div of its own, data-ssd-pin-wrapper, as tall as the element plus the pin distance. The wrapper stays in the page’s flow and keeps the room, so nothing below moves when the element pins. On each scroll frame it reads where the wrapper is on screen: progress is (top − the wrapper’s top) ÷ pinDistance, clamped to 0–1, and one of three states follows (fig. 1).

StateWhenThe element’s inline stylesCallback on the way in
BeforeThe wrapper’s top is below the pin line.Its own: the inline position, top, bottom, left, width and box-sizing it had before scrollPin.onLeaveBack, when it comes from pinned.
PinnedBetween the two: the wrapper’s top has passed the pin line and its last stretch, the element’s height, has not.position: fixed; top: your top; left: the wrapper’s left on screen; width: the wrapper’s width; box-sizing: border-box.onEnter from before, onEnterBack from after.
AfterThe wrapper’s bottom, less the element’s height, is at or above the pin line.position: absolute; bottom: 0; left: 0; width: 100%; box-sizing: border-box, inside the wrapper (position: relative).onLeave, when it comes from pinned.
Jumps
A callback fires only on a change to or from pinned. A jump from before straight to after, or back, in one frame fires none of the four, while onProgress still reports 1 or 0. Take the step from progress, as the generated code does.
Containers
With scrollContainer, scrollPin follows that element’s scroll, but it still measures on screen: top is from the window’s top, and the pinned element is position: fixed. The preview above passes the box’s own top on screen plus your top, and makes the pin again whenever the box moves on screen. In a layout whose scrolling area stays put, set top to where the area starts, once.
Ancestors
A transform, filter, perspective or contain: paint on any ancestor makes that ancestor the containing block of a fixed element, and the pinned stage lands in the wrong place. Keep them off the stage’s ancestors, or use the CSS version below.
04No script

The pure CSS version, and when it is enough

position: sticky holds a stage in every current browser. Put it in a wrapper as tall as the stage plus the pin distance, give it the same top, and it sticks for exactly that much scroll: the same stretch scrollPin pins for. If the stage only has to hold still while copy beside it scrolls past, that is all it takes, and no script is the better answer.

sticky.css11 lines
/* The image holds still while the copy beside it scrolls.
   No script, every browser. */
.feature {
  display: grid;
  grid-template-columns: 1fr 1fr;
  align-items: start; /* stretched to the row, it would have no room to stick */
}
.feature-media {
  position: sticky;
  top: 64px; /* under a 64px header */
}

Changing the steps is the part CSS needs scroll-driven animations for. The CSS only tab gives the tall wrapper a view timeline, measured from the pin line with view-timeline-inset. On that timeline exit-crossing 0 is the moment the wrapper’s top reaches the pin line, where the pin starts, and a length after it is that much more scroll, so each step’s animation-range is its share of the pin distance, whatever the stage’s height. It sits inside @supports: where there are no scroll-driven animations the tour is a plain list, every step showing and nothing pinned.

CSS: sticky and a view timelinescrollPin
Holding the stageposition: sticky, in every current browser.position: fixed, switched on and off by script as the page scrolls.
Changing the stepsA view timeline on the tall wrapper and one animation-range per step: Chrome, Edge 115, Safari (macOS, iOS) 26, Firefox 159. Elsewhere, a plain list.onProgress, in any browser that runs the script.
JavaScriptNone.1.6 KB gzipped, the pin entry on its own.
Your code knowsNothing: no events, no number.onEnter, onLeave, onEnterBack, onLeaveBack, and progress 0 → 1.
The wrapperYou give it its height: the stage plus the pin distance.Added and sized for you, and measured again on resize and when the layout changes.
What breaks itAn ancestor with overflow hidden, auto or scroll between the stage and the page’s scroller, or a parent no taller than the stage.An ancestor with transform, filter, perspective or contain: paint, which becomes the fixed stage’s containing block.
Enough
CSS alone is enough when nothing but the page needs to know where the reader is: a stage that holds still, steps that only change how it looks, and a plain list where timelines are missing. Use scrollPin when your code has to know: to start a video at onEnter, to keep the current step in React state, to draw a canvas or a scrollDraw drawing from the progress, or to count the steps someone saw.
Tested
The CSS only file was scrolled through in Playwright’s Chromium, Firefox and WebKit, and each showed the step the scrollPin version shows at the same scroll. In WebKit a backwards fill did not apply before its range began, so the first step and the undrawn lines are the rules’ own values, not a fill.
05Next step

Use it in your project

scrollPin has an entry of its own, svg-scroll-draw/pin, and the generated code imports it from there.

067 questions

Scroll pin questions

Q1

How do I pin a section on scroll?

Two ways. In CSS, give the section position: sticky and a top, inside a wrapper taller than it: it sticks for the wrapper's height minus its own. In JavaScript, scrollPin from svg-scroll-draw/pin wraps the section in a div as tall as the section plus pinDistance, fixes it top px from the top of the window for that much scroll, and reports progress 0 to 1 through onProgress. Its defaults are top 0 and one window height of scroll.

Q2

How do I build a sticky scrolling feature section where the steps change?

Pin the stage for the whole tour and split the pin distance into equal shares, one per step: the current step is the progress times the number of steps, rounded down, and capped at the last. With scrollPin that is one line in onProgress. In CSS, a view timeline on the tall wrapper and one animation-range per step do the same, measured with exit-crossing from the moment the pin starts. The generator above writes both.

Q3

Can I pin a section on scroll without GSAP?

Yes. position: sticky needs no library at all, and scrollPin is 1.6 KB gzipped on its own. It has GSAP ScrollTrigger's four callback names, onEnter, onLeave, onEnterBack and onLeaveBack, firing at the same two ends of the pin, and onProgress for the 0–1 in between. Where GSAP pins with pin: true and an end, scrollPin takes top and a pinDistance in px or vh. The blog post on pinning without GSAP goes through it.

Q4

Why is position: sticky not working?

Most often an ancestor between the element and the page has overflow set to hidden, auto or scroll: the element then sticks inside that box, which never scrolls, so it never seems to stick. overflow: clip does not do this. Next, the parent: sticky only holds while its parent is on screen, so a parent no taller than the element leaves no room, and a grid or flex item stretched to its row's height is as tall as its parent; align-self: start fixes that. And it needs a top (or bottom) to stick at.

Q5

Does scrollPin work inside a scroll container?

Yes: scrollContainer makes it follow that element's scroll instead of the window's. But top is still measured from the top of the window and the pinned element is position: fixed, so it suits a container that stays put on screen, such as an app layout whose main area scrolls, with top set to where that area starts. If the container moves on screen, make the pin again with its new top, as this page's preview does. An ancestor with transform, filter, perspective or contain: paint moves a fixed element too.

Q6

Why did onEnter or onLeave not fire?

A callback fires only on a change to or from pinned. A jump across the whole pin in one frame, from an anchor link, the End key or scrollTo(), goes straight from before to after without being pinned, so none of the four fire; a page that loads already past the pin does the same. onProgress still reports 1, or 0, so take the current step from onProgress rather than from the callbacks. The preview logs such a jump: press Pin end before the pin starts.

Q7

What pinDistance should I use?

The default is one window height, window.innerHeight, read again on every resize. A number or a px string is pixels and never changes; a vh string such as '300vh' is measured as CSS measures vh, so it follows rotation and window resizes but not a phone's address bar sliding in and out. For a tour, give each step enough scroll to read it: 75vh to 100vh a step is a good start.

More on pinning with svg-scroll-draw 2.23.3: scroll pin without GSAP, the feature tour recipe and scrollPin in the docs.