ScrollStack
A headless layered-stack primitive. Every ScrollStack.Item is a sticky card
sharing the Viewport as its positioning ancestor; as you scroll, each card
pins on top of the previous one and the earlier cards scale down, producing
the familiar stacked-cards transition.
Composition first
ScrollStack ships three primitives — Root, Viewport, Item — and stays out of the way otherwise. Style the cards however you like.
Scroll-driven scale
Each card pins with position: sticky inside a shared parent, then scales down via a motion useScroll value as later cards stack on top.
Smoothed by a spring
Raw scroll progress is fed through useSpring so the scrub feels physical rather than 1:1 with the scroll wheel. Tune or disable it per-Root.
Respects motion preferences
useReducedMotion short-circuits the scale to 1 so users with reduced-motion preferences see a calm, non-animated stack.
import { ScrollStack } from "@togetheragency/ui/scroll-stack";
export function StackedCards() {
return (
<ScrollStack.Root useViewportScroll topOffset={16} stackGap={12} itemDistance={120}>
<ScrollStack.Viewport className="h-80 rounded-2xl border bg-background p-4">
{cards.map((card) => (
<ScrollStack.Item key={card.id}>
<div className="flex h-56 flex-col justify-between rounded-2xl bg-foreground p-6 text-background">
{/* card content */}
</div>
</ScrollStack.Item>
))}
</ScrollStack.Viewport>
</ScrollStack.Root>
);
}Import
import { ScrollStack } from "@togetheragency/ui/scroll-stack";The component is exported as a compound object; every sub-component is a
property of ScrollStack:
ScrollStack.Root;
ScrollStack.Viewport;
ScrollStack.Item;Composition
ScrollStack has three parts: Root → Viewport → Item. Items must be direct
children of Viewport; they all need to share the same sticky-positioning
ancestor so that they can pin on top of one another instead of each card
disappearing as it leaves its own parent.
<ScrollStack.Root useViewportScroll>
<ScrollStack.Viewport className="h-[80vh]">
<ScrollStack.Item>
<div className="rounded-2xl bg-card p-6">Card one</div>
</ScrollStack.Item>
<ScrollStack.Item>
<div className="rounded-2xl bg-card p-6">Card two</div>
</ScrollStack.Item>
</ScrollStack.Viewport>
</ScrollStack.Root>ScrollStack.Rootholds shared configuration —topOffset,stackGap,scaleStep,itemDistance,spring, and theuseViewportScrollswitch. It runs a singleuseScroll(against the page or the Viewport) and feeds the smoothed scroll position to every Item.ScrollStack.Viewportis the scroll container and the shared positioning ancestor for the items. By default it’s a passive wrapper and the page scroll drives the animation; passuseViewportScrollon Root and the Viewport gainsrelative overflow-y-autoso it scrolls itself.ScrollStack.Itemis the visual card. It renders a motion<div>withposition: sticky; top: topOffset + index * stackGap, plus amargin-bottomofitemDistance(except for the last one) to create the scroll distance that separates pin events. Itsscaleis driven by the Root’s scroll progress, mapped from[pinStart, lastPinStart]to[1, 1 - depthFromTop * scaleStep].
Sticky positioning is the key. All items share Viewport as their positioning
parent, so when the next item pins on top, the previous one stays pinned
too — they visually stack with stackGap between their pinned tops. Each
Item measures its own flow position with a ResizeObserver and reports it
back to the Root, which is how the scale ranges stay correct across
resizes, font loads, and content changes.
Usage
Basic usage
The minimum: a Root with useViewportScroll, a Viewport sized via Tailwind,
and one Item per card. itemDistance controls how much scroll separates
each card’s pin event from the next.
<ScrollStack.Root useViewportScroll itemDistance={120} className="w-full max-w-md">
<ScrollStack.Viewport className="h-80 rounded-2xl border bg-background p-4">
{cards.map((card) => (
<ScrollStack.Item key={card.id}>
<div className="flex h-56 flex-col justify-between rounded-2xl bg-card p-6">
{/* card content */}
</div>
</ScrollStack.Item>
))}
</ScrollStack.Viewport>
</ScrollStack.Root>Window scroll
Omit useViewportScroll and the page scroll drives the animation. The
Viewport is just a wrapper; cards still pin to the top of the window via
position: sticky. Bump topOffset past any sticky header, and use a
larger itemDistance so the cards aren’t pinned right on top of each other.
<ScrollStack.Root topOffset={96} stackGap={16} itemDistance={400}>
<ScrollStack.Viewport>
{sections.map((section) => (
<ScrollStack.Item key={section.id}>
<div className="rounded-3xl bg-card p-10">{/* section content */}</div>
</ScrollStack.Item>
))}
</ScrollStack.Viewport>
</ScrollStack.Root>Tight stack
Smaller stackGap, scaleStep, and itemDistance keep the stack compact —
earlier cards peek out by a sliver, barely shrink, and the transitions
happen quickly. Good for dense content where the stack is decorative rather
than the focal point.
Composition first
ScrollStack ships three primitives — Root, Viewport, Item — and stays out of the way otherwise. Style the cards however you like.
Scroll-driven scale
Each card pins with position: sticky inside a shared parent, then scales down via a motion useScroll value as later cards stack on top.
Smoothed by a spring
Raw scroll progress is fed through useSpring so the scrub feels physical rather than 1:1 with the scroll wheel. Tune or disable it per-Root.
Respects motion preferences
useReducedMotion short-circuits the scale to 1 so users with reduced-motion preferences see a calm, non-animated stack.
<ScrollStack.Root
useViewportScroll
topOffset={8}
stackGap={4}
scaleStep={0.02}
itemDistance={80}
>
<ScrollStack.Viewport className="h-80 rounded-2xl border bg-background p-4">
{cards.map((card) => (
<ScrollStack.Item key={card.id}>
<Card {...card} />
</ScrollStack.Item>
))}
</ScrollStack.Viewport>
</ScrollStack.Root>Stepped stack
Larger stackGap, scaleStep, and itemDistance give a more dramatic,
visibly-tiered look — each card sits visibly behind the next and shrinks
noticeably.
Composition first
ScrollStack ships three primitives — Root, Viewport, Item — and stays out of the way otherwise. Style the cards however you like.
Scroll-driven scale
Each card pins with position: sticky inside a shared parent, then scales down via a motion useScroll value as later cards stack on top.
Smoothed by a spring
Raw scroll progress is fed through useSpring so the scrub feels physical rather than 1:1 with the scroll wheel. Tune or disable it per-Root.
Respects motion preferences
useReducedMotion short-circuits the scale to 1 so users with reduced-motion preferences see a calm, non-animated stack.
<ScrollStack.Root
useViewportScroll
topOffset={24}
stackGap={24}
scaleStep={0.08}
itemDistance={180}
>
<ScrollStack.Viewport className="h-80 rounded-2xl border bg-background p-4">
{cards.map((card) => (
<ScrollStack.Item key={card.id}>
<Card {...card} />
</ScrollStack.Item>
))}
</ScrollStack.Viewport>
</ScrollStack.Root>Tuning the spring
Scroll progress is fed through useSpring before being mapped to the scale.
Override spring to change the feel — stiffer + less mass is snappier and
tracks the wheel closer; softer is more cinematic. Pass any SpringOptions
from motion/react.
Composition first
ScrollStack ships three primitives — Root, Viewport, Item — and stays out of the way otherwise. Style the cards however you like.
Scroll-driven scale
Each card pins with position: sticky inside a shared parent, then scales down via a motion useScroll value as later cards stack on top.
Smoothed by a spring
Raw scroll progress is fed through useSpring so the scrub feels physical rather than 1:1 with the scroll wheel. Tune or disable it per-Root.
Respects motion preferences
useReducedMotion short-circuits the scale to 1 so users with reduced-motion preferences see a calm, non-animated stack.
<ScrollStack.Root
useViewportScroll
spring={{ stiffness: 500, damping: 50, mass: 0.4 }}
>
<ScrollStack.Viewport className="h-80 rounded-2xl border bg-background p-4">
{cards.map((card) => (
<ScrollStack.Item key={card.id}>
<Card {...card} />
</ScrollStack.Item>
))}
</ScrollStack.Viewport>
</ScrollStack.Root>Disabling the spring
Pass spring={false} to drive scale directly from raw scroll progress.
The animation becomes 1:1 with the scroll position — no inertia, no
overshoot.
Composition first
ScrollStack ships three primitives — Root, Viewport, Item — and stays out of the way otherwise. Style the cards however you like.
Scroll-driven scale
Each card pins with position: sticky inside a shared parent, then scales down via a motion useScroll value as later cards stack on top.
Smoothed by a spring
Raw scroll progress is fed through useSpring so the scrub feels physical rather than 1:1 with the scroll wheel. Tune or disable it per-Root.
Respects motion preferences
useReducedMotion short-circuits the scale to 1 so users with reduced-motion preferences see a calm, non-animated stack.
<ScrollStack.Root useViewportScroll spring={false}>
<ScrollStack.Viewport className="h-80 rounded-2xl border bg-background p-4">
{cards.map((card) => (
<ScrollStack.Item key={card.id}>
<Card {...card} />
</ScrollStack.Item>
))}
</ScrollStack.Viewport>
</ScrollStack.Root>Reduced motion
useReducedMotion is honored automatically: when the user has
prefers-reduced-motion: reduce, the scale transform short-circuits to 1
on every Item, so the cards still pin in a stack but don’t shrink.
API Reference
ScrollStack.Root
Renders a <div> and owns the shared configuration, item registry, and
scroll-driven MotionValue. Extends all native div props.
Data attributes: data-slot="scroll-stack-root".
ScrollStack.Viewport
The scroll container and shared positioning ancestor for the items. When
useViewportScroll is set on Root, this element gets
relative overflow-y-auto applied so useScroll can target it.
Data attributes:
data-slot="scroll-stack-viewport",
data-scroll-container (empty string when useViewportScroll is on, otherwise absent).
ScrollStack.Item
A single sticky card. Renders a motion <div> with
position: sticky; top: topOffset + index * stackGap, a scroll-driven
scale transform, and margin-bottom: itemDistance (except for the last
item).
Data attributes:
data-slot="scroll-stack-item",
data-index="<number>".
Sizing tips
- The card’s height comes from its children. Give the inner element a
fixed height (e.g.
h-56) or let it size to content. itemDistanceis the single knob for inter-card scroll distance. Larger values mean each pin event takes longer to reach, so the animation feels more deliberate; smaller values feel snappier.- With
useViewportScroll, set the Viewport height explicitly (h-80,h-[80vh]). Without it, the Viewport is just a wrapper and the document is the scrolling ancestor — sticky pins to the window top. - Make sure
Itemis a direct child ofViewport. Wrapping items in another<div>breaks the sticky stack, because each card would only share a parent with itself.
Exported types
import type {
ScrollStackItemProps,
ScrollStackRootProps,
ScrollStackViewportProps,
} from "@togetheragency/ui/scroll-stack";