stacked-scroll
v1.0.2
Published
A small, hardened library that turns your sections into a stack of pages: each header stays pinned while you read, then hands off cleanly to the next page as it slides up from below.
Maintainers
Readme
stacked-scroll
A small, hardened library that turns your sections into a stack of pages: each header stays pinned while you read, then hands off cleanly to the next page as it slides up from below.
Built on top of GSAP ScrollTrigger.
Status
v1.0.0 — stable and tested. Used in production on rotyounges.com.
Install
npm install stacked-scroll gsapgsap is a peer dependency.
Quick start
Your HTML is a stack of sections. Each section needs a header and a main content area.
<section class="panel" id="intro">
<header class="header">Intro</header>
<main class="main">…</main>
</section>
<section class="panel" id="work">
<header class="header">Work</header>
<main class="main">…</main>
</section>import StackedScroll from 'stacked-scroll';
const scroller = new StackedScroll({
sections: '.panel',
headerSelector: '.header',
mainSelector: '.main'
});
scroller.init();
// Deep-link to a section
scroller.scrollTo('work');CSS requirements
The library only handles the JavaScript side of pinning. Your CSS must cooperate with GSAP:
- Do not add global
position: relativeorz-indexto your section elements — this breaks GSAP's pinning behavior. - Headers should use
position: sticky; top: 0if you want them to stay visible while the section content scrolls. - Use
min-height: 100vh(or100dvh) on sections so the pin-start logic can distinguish tall vs short panels.
See demo/index.html for a working example.
Hiding headers on short viewports
If you enable hideHeadersBelow, StackedScroll adds the configured class to
<html> whenever the viewport is shorter than the threshold. You then decide
what happens to the per-section header via CSS. Keep your site's primary
navigation outside the section header so it remains accessible when the header
is hidden.
/* Hide the per-section title bar on short viewports */
.stacked-scroll-compact .header {
display: none;
}
/* Or collapse it instead of hiding it */
.stacked-scroll-compact .header {
height: 2rem;
padding: 0.25rem 1rem;
font-size: 0.75rem;
}Configuration
new StackedScroll({
// Selector string, NodeList, array, or single HTMLElement
sections: '.panel',
// Selector for the sticky header inside each section (used for offset compensation)
headerSelector: '.header',
// Selector for the default scroll target inside each section
mainSelector: '.main',
// Optional: provide your own spacer element instead of the auto-created one.
// The spacer must be outside all sections.
spacerSelector: null,
// Debounce window resize events
resizeDebounceMs: 200,
// Default scroll behavior for scrollTo()
scrollBehavior: 'smooth',
// Optional: when the viewport height (px) drops below this value, add
// compactClassName to <html> so you can hide/collapse per-section headers.
hideHeadersBelow: null,
// Class added to <html> when the viewport is shorter than hideHeadersBelow.
compactClassName: 'stacked-scroll-compact'
});API
init()
Set up ScrollTriggers, the scroll-end spacer, and event listeners. Returns the instance.
scroller.init();scrollTo(target, options)
Scroll to a section or to a specific element within a section.
// Scroll to a section by id
scroller.scrollTo('work');
// Scroll to a custom element inside the section
scroller.scrollTo('work', { offsetElement: '#work .lead' });
// Override the scroll behavior for this call
scroller.scrollTo('work', { behavior: 'auto' });refresh()
Recalculate spacer sizing and refresh ScrollTrigger measurements. Call this after your content changes height.
scroller.refresh();destroy()
Kill all ScrollTriggers, remove listeners, and remove the auto-created spacer.
scroller.destroy();Why this exists
GSAP ScrollTrigger provides the low-level pin primitive, but packaging the specific "read the whole section, then hard-cut to the next" behavior involves several non-obvious edge cases:
- Deep links under pinned sections —
offsetTop/offsetParentbecome unreliable when GSAP setsposition: fixedon the active panel.scrollTo()avoids them entirely by summing natural panel heights and using a same-tickgetBoundingClientRect()difference. - Short last panel — when the last section is shorter than the viewport, the browser physically cannot scroll it flush to the top.
stacked-scrolladds an invisible spacer sized to exactly the missing height. - Final transition — when the last section is shorter than the viewport, the previous panel's pin
endcan land on the document's maximum scroll position, so GSAP can never scroll past it and the panel staysposition: fixed. StackedScroll clamps every non-final pin end to just belowmaxScrollso the handoff always completes. - Adjacent short panels — two consecutive sections both shorter than the viewport do not provide enough natural scroll distance for the previous pin to release before the next one engages.
stacked-scrollinjects a synthetic settle buffer between them so the handoff stays clean. The settle buffer fixes the scroll behavior, but a genuinely short panel (withoutmin-height: 100vh) doesn't physically fill the viewport when pinned — the previous section's content will show through the gap. Keep your panels atmin-height: 100vhand this never comes up.
Demo
npm install
npm run build
npx serveThen open http://localhost:3000/demo/.
Tests
npm run test:e2ePlaywright regression tests cover the spacer fix and deep-link landing tolerance, including a fixture that reproduces the original pinned-section deep-link bug.
License
MIT
