A pinned section whose content scrubs through keyframed steps as the user scrolls its track. A sticky frame pins while spring-smoothed scroll progress drives each step's opacity, rise, and blur window, with a dot rail that mirrors and click-scrolls to the active step.
npx shadcn@latest add @paragon/scroll-scrub"use client";
import * as React from "react";
import {
motion,
useMotionValueEvent,
useReducedMotion,
useScroll,
useSpring,
useTransform,
type MotionValue,
} from "motion/react";
import { cn } from "@/lib/utils";
interface ScrollScrubContextValue {
progress: MotionValue<number>;
total: number;
reduced: boolean;
}
const ScrollScrubContext = React.createContext<ScrollScrubContextValue | null>(
null,
);
export interface ScrollScrubProps
extends Omit<React.ComponentProps<"div">, "children"> {
/** One `ScrollScrubStep` per scene. */
children: React.ReactNode;
/** Height of the pinned frame, px. */
height?: number;
/** Scroll distance each step owns, px. Longer = slower scrub. */
stepHeight?: number;
/**
* The scrollable ancestor when the scrub lives inside an
* `overflow-y-auto` container instead of the page.
*/
containerRef?: React.RefObject<HTMLElement | null>;
/** Hide the clickable step indicator rail. */
hideIndicators?: boolean;
/** Called when the active step changes. */
onStepChange?: (index: number) => void;
}
/**
* A pinned section whose content scrubs through keyframed steps as the user
* scrolls its track. The track is `steps × stepHeight` taller than the frame;
* a `position: sticky` frame pins while `useScroll` progress (0–1 across the
* pinned distance, smoothed through a tight spring so wheel ticks don't
* step) drives each step's opacity / rise / blur window. Transforms clamp at
* both ends, so overscroll never rubber-bands the scene. A dot rail mirrors
* the active step and click-scrolls the container to that step's offset.
* Reduced motion keeps the crossfade but drops all movement and blur.
*/
export function ScrollScrub({
children,
height = 320,
stepHeight = 320,
containerRef,
hideIndicators = false,
onStepChange,
className,
style,
...props
}: ScrollScrubProps) {
const trackRef = React.useRef<HTMLDivElement>(null);
const reduced = useReducedMotion() ?? false;
const steps = React.Children.toArray(children);
const total = Math.max(steps.length, 1);
const { scrollYProgress } = useScroll({
target: trackRef,
container: containerRef,
offset: ["start start", "end end"],
});
// Tight spring: glued to the scroll position but smooths discrete wheel
// ticks. Reduced motion tracks the raw value exactly.
const smoothed = useSpring(scrollYProgress, {
stiffness: 400,
damping: 40,
restDelta: 0.001,
});
const progress = reduced ? scrollYProgress : smoothed;
const [active, setActive] = React.useState(0);
const activeRef = React.useRef(0);
useMotionValueEvent(scrollYProgress, "change", (v) => {
const next = Math.min(total - 1, Math.max(0, Math.floor(v * total)));
if (next !== activeRef.current) {
activeRef.current = next;
setActive(next);
onStepChange?.(next);
}
});
const scrollToStep = (index: number) => {
const track = trackRef.current;
if (!track) return;
const container = containerRef?.current ?? null;
// Scroll offset that parks progress mid-way through step `index`.
const target = (index + 0.5) * stepHeight;
const behavior: ScrollBehavior = reduced ? "auto" : "smooth";
if (container) {
const trackTop =
track.getBoundingClientRect().top -
container.getBoundingClientRect().top +
container.scrollTop;
container.scrollTo({ top: trackTop + target, behavior });
} else {
const trackTop = track.getBoundingClientRect().top + window.scrollY;
window.scrollTo({ top: trackTop + target, behavior });
}
};
const context = React.useMemo(
() => ({ progress, total, reduced }),
[progress, total, reduced],
);
return (
<ScrollScrubContext.Provider value={context}>
<div
ref={trackRef}
data-slot="scroll-scrub"
className={cn("relative", className)}
style={{ height: height + total * stepHeight, ...style }}
{...props}
>
<div
className="sticky top-0 overflow-hidden"
style={{ height }}
>
{steps.map((step, index) => (
<ScrollScrubScene key={index} index={index}>
{step}
</ScrollScrubScene>
))}
{!hideIndicators && total > 1 && (
<div className="absolute top-1/2 right-3 z-10 flex -translate-y-1/2 flex-col gap-2">
{steps.map((_, index) => (
<button
key={index}
type="button"
aria-label={`Go to step ${index + 1} of ${total}`}
aria-current={index === active ? "step" : undefined}
onClick={() => scrollToStep(index)}
className="group relative flex size-2 items-center justify-center after:absolute after:-inset-2.5 after:content-['']"
>
<span
className={cn(
"size-1.5 rounded-full transition-[scale,background-color] duration-150 ease-out",
index === active
? "scale-125 bg-primary"
: "bg-muted-foreground/35 group-hover:bg-muted-foreground/60",
)}
/>
</button>
))}
</div>
)}
</div>
</div>
</ScrollScrubContext.Provider>
);
}
/** Internal: positions one step and drives its scrub window. */
function ScrollScrubScene({
index,
children,
}: {
index: number;
children: React.ReactNode;
}) {
const context = React.useContext(ScrollScrubContext);
if (!context) throw new Error("ScrollScrubStep must be inside ScrollScrub");
const { progress, total, reduced } = context;
const isFirst = index === 0;
const isLast = index === total - 1;
const start = index / total;
const end = (index + 1) / total;
// Hand-off zone: the leading fifth of each segment crossfades with the
// previous step. Clamped transforms keep the ends dead still on overscroll.
const fade = 0.2 / total;
const opacity = useTransform(
progress,
[start, start + (isFirst ? 0 : fade), end, end + (isLast ? 1 : fade)],
[isFirst ? 1 : 0, 1, 1, isLast ? 1 : 0],
);
const y = useTransform(
progress,
[start, start + fade, end, end + fade],
reduced
? [0, 0, 0, 0]
: [isFirst ? 0 : 24, 0, 0, isLast ? 0 : -24],
);
const scale = useTransform(
progress,
[start, start + fade, end, end + fade],
reduced
? [1, 1, 1, 1]
: [isFirst ? 1 : 0.96, 1, 1, isLast ? 1 : 0.96],
);
const blurPx = useTransform(
progress,
[start, start + fade, end, end + fade],
reduced ? [0, 0, 0, 0] : [isFirst ? 0 : 4, 0, 0, isLast ? 0 : 4],
);
const filter = useTransform(blurPx, (v) =>
v < 0.05 ? "none" : `blur(${v.toFixed(2)}px)`,
);
// Steps outside their window must not intercept the pointer.
const pointerEvents = useTransform(opacity, (v) =>
v < 0.5 ? ("none" as const) : ("auto" as const),
);
return (
<motion.div
data-slot="scroll-scrub-step"
className="absolute inset-0"
style={{ opacity, y, scale, filter, pointerEvents }}
>
{children}
</motion.div>
);
}
export interface ScrollScrubStepProps extends React.ComponentProps<"div"> {}
/**
* One scene of a ScrollScrub. A plain layout surface — the parent drives all
* motion, so anything inside (cards, charts, copy) scrubs as a unit.
*/
export function ScrollScrubStep({ className, ...props }: ScrollScrubStepProps) {
return (
<div
data-slot="scroll-scrub-step-content"
className={cn("flex size-full items-center justify-center", className)}
{...props}
/>
);
}