Scroll Scrub
Layout

Scroll Scrub

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.

Install

npx shadcn@latest add @paragon/scroll-scrub

scroll-scrub.tsx

"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}
    />
  );
}