npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

stack-on-scroll

v2.0.2

Published

Sticky scroll-stacking cards for React. Zero dependencies, zero config, and no JavaScript on the default path.

Downloads

386

Readme

stack-on-scroll

Sticky scroll-stacking cards for React. Each card pins itself a little lower than the one before it, so scrolling deals them into a fanned pile with every card's top edge still showing.

See it running → — a live demo with sliders for every prop, so you can feel what each one does before installing anything.

npm license

  • No runtime dependencies. React is a peer, nothing else is installed.
  • No JavaScript on the default path. The stacking is position: sticky. Effects are opt-in and only then does a frame loop start.
  • No CSS to import. Everything is inline styles, so there is no stylesheet to forget and no global class name to collide with yours.
  • Works with SSR and React Server Components. Nothing touches window during render and the bundles carry a "use client" directive.
  • ~4.7 KB minified, before gzip.

Install

npm install stack-on-scroll

React 18 or newer. Node 22 or newer to build from source.

Use it

import { StackContainer, StackCard } from 'stack-on-scroll';

export default function Chapters() {
  return (
    <StackContainer offset={32}>
      <StackCard>
        <h2>First</h2>
      </StackCard>
      <StackCard>
        <h2>Second</h2>
      </StackCard>
      <StackCard>
        <h2>Third</h2>
      </StackCard>
    </StackContainer>
  );
}

Cards are numbered for you, so generating them from data needs nothing extra:

<StackContainer offset={32}>
  {chapters.map((chapter) => (
    <StackCard key={chapter.id}>{chapter.title}</StackCard>
  ))}
</StackContainer>

Add movement

Set any of these and cards animate as the next one slides over them. Leave them alone and no scroll listener is ever attached.

<StackContainer offset={28} scaleStep={0.06} fadeStep={0.35} rotateStep={-1.5}>
  {/* … */}
</StackContainer>

Each value is the total change applied by the time a card is fully covered: scaleStep={0.06} finishes at 94% size, fadeStep={0.35} at 65% opacity.

All animated cards share one scroll listener and one animation frame, so a twenty-card stack costs the same per frame as a two-card one.

Props

Both components take the same layout and effect props. Anything set on a card overrides the container.

| Prop | Type | Default | What it does | | --- | --- | --- | --- | | offset | number \| string | 25 | Gap between pinned edges — the visible sliver of each covered card | | height | number \| string | '100vh' | Height of each card | | inset | number \| string | 0 | Where the first card pins, measured from the viewport edge | | stackFrom | 'top' \| 'bottom' | 'top' | Pin to the top and stack down, or the bottom and stack up | | scaleStep | number | 0 | How far a card shrinks once fully covered | | fadeStep | number | 0 | How far a card fades once fully covered | | rotateStep | number | 0 | How far a card tilts, in degrees, once fully covered | | respectReducedMotion | boolean | true | Skip scale and rotation when the reader prefers reduced motion. Fading still applies | | as | ElementType | 'div' | Element to render |

Numbers mean pixels. Strings pass through untouched, so offset="2rem" and height="100svh" both work.

StackCard also takes:

| Prop | Type | What it does | | --- | --- | --- | | index | number | Set the position by hand instead of letting the container count | | innerClassName | string | Class for the inner element that carries the transform | | innerStyle | CSSProperties | Styles for that inner element |

Every other prop — className, style, id, onClick, aria-*, ref — goes straight to the outer element.

Styling

Two elements per card, both stable:

<div class="sos-card cardContainer" style="position:sticky; top:50px; height:100vh">
  <div class="sos-card-inner">your children</div>
</div>

The outer element does the pinning. The inner element centres your content and carries any transform, which keeps the measured box unscaled — measuring a scaled box would feed the scale back into the maths and drift.

Style either one:

.sos-card-inner {
  border-radius: 18px;
  box-shadow: 0 -8px 40px rgb(0 0 0 / 0.12);
}

cardContainer is kept from v1 so stylesheets written against the old version keep working.

Build your own effects

Animated cards publish their coverage as a custom property on the outer element, from 0 to 1:

.sos-card-inner {
  filter: blur(calc(var(--sos-p, 0) * 4px));
}

For anything CSS cannot express, useStackProgress gives you the same number in JavaScript. It runs inside the shared animation frame and never re-renders, so you can write to the DOM from it directly:

import { useStackProgress } from 'stack-on-scroll';

function Counter() {
  const labelRef = useRef<HTMLSpanElement>(null);

  const ref = useStackProgress<HTMLDivElement>({
    offset: 32,
    onProgress: (p) => {
      if (labelRef.current) labelRef.current.textContent = `${Math.round(p * 100)}%`;
    },
  });

  return (
    <StackCard ref={ref}>
      covered <span ref={labelRef}>0%</span>
    </StackCard>
  );
}

refreshStack() forces a recalculation if you change layout in a way scroll and resize events would not catch.

Notes

Cards must be direct children of the container. Sticky positioning is scoped to the parent element, and coverage is measured against the next sibling. A fragment is fine — the container looks through it — but a wrapper <div> is not.

Give the last card room. The last card in a stack is never covered, so it has nothing after it to scroll against.

Next.js App Router works with no ceremony; the bundles are marked as client code. The components can be imported directly into a server component.

Browser support

position: sticky is the only requirement, so anything from the last decade. The effects use getBoundingClientRect and requestAnimationFrame, both equally old. Nothing here needs a polyfill.

Upgrading from v1

The old names still work and the default layout is unchanged, so most projects need no edits:

import { Card, Outer } from 'stack-on-scroll';

<Outer>
  <Card index={0}>…</Card>
  <Card index={1}>…</Card>
</Outer>;

Three things did change:

  1. React 18 is now the minimum. v1 accidentally bundled React 19's JSX runtime, so its real support range was never what it claimed.
  2. Outer renders a <div>, not a <main>. A library should not decide your page has its <main> here. Pass as="main" to get the old markup.
  3. No stylesheet is injected any more. v1 pushed a global .cardContainer rule into the document at runtime; those styles are now inline on the element. Visually identical, but a .cardContainer rule of your own no longer has to fight it.

index is now optional. Dropping it lets the container do the counting.

Contributing

npm install
npm run verify   # typecheck, build, test, and lint the package manifest

npm run test:watch while you work. demo.html opens in a browser with no build step and exercises every prop against the built bundle — serve it over HTTP, since ES modules will not load from file://:

npm run build
npx serve .        # http://localhost:3000/demo.html

The hosted version at stack-on-scroll.vercel.app lives in its own repository.

License

MIT © Saad Ahmad