Scroll-stacking cards built on pure CSS position: sticky, with a tiny scroll-linked scale and fade as each card is covered by the next.
npx shadcn@latest add @paragon/sticky-stack"use client";
import * as React from "react";
import {
motion,
useReducedMotion,
useScroll,
useTransform,
type MotionValue,
} from "motion/react";
import { cn } from "@/lib/utils";
interface StickyStackContextValue {
progress: MotionValue<number>;
total: number;
}
const StickyStackContext = React.createContext<StickyStackContextValue | null>(
null,
);
export interface StickyStackProps extends React.ComponentProps<"div"> {
/** Sticky offset from the scroll edge, px. */
topOffset?: number;
/** Extra offset added per card so stacked headers peek out, px. */
peek?: number;
/**
* The scrollable ancestor, when the stack lives inside an
* `overflow-y-auto` container instead of the page.
*/
scrollContainerRef?: React.RefObject<HTMLElement | null>;
}
/**
* Scroll-stacking cards. Positioning is pure CSS `position: sticky` with
* per-card top offsets; the only JS is a scroll-linked scale/fade (via
* useScroll) that settles covered cards to 0.97 as the next one slides
* over. Under reduced motion cards still stack — they just skip the scale.
*/
export function StickyStack({
topOffset = 64,
peek = 12,
scrollContainerRef,
className,
children,
...props
}: StickyStackProps) {
const ref = React.useRef<HTMLDivElement>(null);
const { scrollYProgress } = useScroll({
target: ref,
container: scrollContainerRef,
offset: ["start start", "end end"],
});
const items = React.Children.toArray(children);
const total = items.length;
const context = React.useMemo(
() => ({ progress: scrollYProgress, total }),
[scrollYProgress, total],
);
return (
<StickyStackContext.Provider value={context}>
<div
ref={ref}
data-slot="sticky-stack"
className={cn("flex flex-col gap-6", className)}
style={
{
"--sticky-stack-top": `${topOffset}px`,
"--sticky-stack-peek": `${peek}px`,
} as React.CSSProperties
}
{...props}
>
{items.map((child, index) => (
<StickyStackItem key={index} index={index}>
{child}
</StickyStackItem>
))}
</div>
</StickyStackContext.Provider>
);
}
function StickyStackItem({
index,
children,
}: {
index: number;
children: React.ReactNode;
}) {
const context = React.useContext(StickyStackContext);
if (!context) throw new Error("StickyStackItem must be inside StickyStack");
const { progress, total } = context;
const reducedMotion = useReducedMotion();
// Card i settles as the scroll moves through segment [i, i+1] of the
// container; the last card never has anything stacked over it.
const isLast = index === total - 1;
const start = (index + 0.25) / total;
const end = (index + 1) / total;
const scale = useTransform(
progress,
[start, end],
isLast || reducedMotion ? [1, 1] : [1, 0.97],
);
const opacity = useTransform(
progress,
[start, end],
isLast || reducedMotion ? [1, 1] : [1, 0.82],
);
return (
<motion.div
data-slot="sticky-stack-item"
style={{
scale,
opacity,
top: `calc(var(--sticky-stack-top) + ${index} * var(--sticky-stack-peek))`,
}}
className="sticky origin-top"
>
{children}
</motion.div>
);
}
export interface StickyStackCardProps extends React.ComponentProps<"div"> {}
/** A default card surface for StickyStack children — optional sugar. */
export function StickyStackCard({
className,
...props
}: StickyStackCardProps) {
return (
<div
data-slot="sticky-stack-card"
className={cn(
"rounded-xl bg-card p-6 text-card-foreground shadow-overlay",
className,
)}
{...props}
/>
);
}