Overview

A story told one chapter at a time inside a scrolling panel. Each chapter sharpens into focus as it reaches the middle and blurs away as it leaves, and its visual drifts and turns slightly with the scroll. A rail beside the panel fills continuously, marks the current chapter with a sliding highlight, and jumps to any chapter. On small screens the rail becomes story-style segments above the panel. A rolling counter with previous and next buttons sits in the corner.


Installation

Install dependencies:

$ pnpm add motion

Add the utility function for class merging:

lib/utils.ts
tsx
import { ClassValue, clsx } from "clsx";import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {  return twMerge(clsx(inputs));}

Copy the component code into your project:

components/ui/story-scroller.tsx
tsx
"use client";
import { useId, useRef, useState } from "react";import type { ReactNode, RefObject } from "react";import {  AnimatePresence,  LayoutGroup,  motion,  useMotionValueEvent,  useReducedMotion,  useScroll,  useSpring,  useTransform,} from "motion/react";import { cn } from "@/lib/utils";
export type StoryChapter = {  id: string;  title: string;  body: string;  /** Short label above the title. Defaults to "Chapter 01". */  kicker?: string;  visual?: ReactNode;};
export type StoryScrollerProps = {  chapters: StoryChapter[];  onChapterChange?: (index: number) => void;  /** Height of the story panel. */  height?: string;  className?: string;};
const EASE_OUT = [0.23, 1, 0.32, 1] as const;
export function StoryScroller({ chapters, onChapterChange, height = "min(26rem, 70vh)", className }: StoryScrollerProps) {  const reduce = !!useReducedMotion();  const scrollRef = useRef<HTMLDivElement>(null);  const [active, setActive] = useState(0);  const activeRef = useRef(0);  const uid = useId();  const count = chapters.length;
  const { scrollYProgress } = useScroll({ container: scrollRef });  const fill = useSpring(scrollYProgress, { stiffness: 260, damping: 40 });  const hintOpacity = useTransform(scrollYProgress, [0, 0.06], [1, 0]);
  // The active chapter is whichever one crosses 40% of the panel height.  useMotionValueEvent(scrollYProgress, "change", () => {    const root = scrollRef.current;    if (!root) return;    const line = root.scrollTop + root.clientHeight * 0.4;    const nodes = root.querySelectorAll<HTMLElement>("[data-story-index]");    let next = 0;    nodes.forEach((n, i) => {      if (n.offsetTop <= line) next = i;    });    if (root.scrollTop + root.clientHeight >= root.scrollHeight - 2) next = nodes.length - 1;    if (next === activeRef.current) return;    activeRef.current = next;    setActive(next);    onChapterChange?.(next);  });
  const jumpTo = (index: number) => {    const root = scrollRef.current;    const target = root?.querySelector<HTMLElement>(`[data-story-index="${index}"]`);    if (!root || !target) return;    root.scrollTo({ top: target.offsetTop, behavior: reduce ? "instant" : "smooth" });  };
  if (!count) return null;  const current = Math.min(active, count - 1);
  return (    <div className={cn("grid gap-5 sm:grid-cols-[180px_minmax(0,1fr)] sm:gap-8", className)}>      {/* Chapter rail (from sm) */}      <nav aria-label="Story chapters" className="relative hidden self-center sm:block">        <div aria-hidden className="absolute top-4 bottom-4 left-[13px] w-[2px] overflow-hidden rounded-full bg-(--color-rule)">          <motion.div className="h-full w-full origin-top rounded-full bg-(--color-ink)" style={{ scaleY: reduce ? scrollYProgress : fill }} />        </div>        <LayoutGroup id={uid}>          <div role="list" className="relative flex flex-col gap-0.5">            {chapters.map((chapter, index) => {              const on = index === current;              const done = index < current;              return (                <div role="listitem" key={chapter.id}>                  <button                    type="button"                    aria-current={on ? "step" : undefined}                    onClick={() => jumpTo(index)}                    className="group relative flex min-h-10 w-full items-center gap-3 rounded-[10px] py-1.5 pr-2 pl-[7px] text-left outline-none focus-visible:ring-2 focus-visible:ring-(--color-focus)"                  >                    {on ? (                      <motion.span                        layoutId="story-rail-active"                        aria-hidden                        className="absolute inset-0 rounded-[10px] bg-(--color-paper-2) shadow-[inset_0_0_0_1px_var(--color-rule)]"                        transition={reduce ? { duration: 0 } : { type: "spring", bounce: 0.2, duration: 0.45 }}                      />                    ) : null}                    <span aria-hidden className="relative grid size-[14px] shrink-0 place-items-center">                      {on && !reduce ? (                        <motion.span                          className="absolute inset-0 rounded-full bg-(--color-ink)"                          animate={{ scale: [1, 2], opacity: [0.25, 0] }}                          transition={{ duration: 1.6, repeat: Infinity, ease: "easeOut" }}                        />                      ) : null}                      <motion.span                        className="relative block rounded-full"                        initial={false}                        animate={{                          width: on ? 12 : 9,                          height: on ? 12 : 9,                          backgroundColor: on || done ? "var(--color-ink)" : "var(--color-paper)",                        }}                        transition={{ type: "spring", bounce: 0.4, duration: 0.4 }}                        style={{ boxShadow: "0 0 0 2px var(--color-paper), inset 0 0 0 1.5px var(--color-ink)" }}                      />                    </span>                    <span className="relative min-w-0">                      <span className="block font-mono text-[10.5px] text-(--color-neutral) tabular-nums">{String(index + 1).padStart(2, "0")}</span>                      <span                        className={cn(                          "block truncate text-[13.5px] transition-colors duration-200",                          on ? "font-semibold text-(--color-ink)" : "text-(--color-neutral) group-hover:text-(--color-ink-2)",                        )}                      >                        {chapter.title}                      </span>                    </span>                  </button>                </div>              );            })}          </div>        </LayoutGroup>      </nav>
      <div className="relative">        {/* Story segments (below sm) */}        <div aria-hidden className="mb-3 flex gap-1.5 sm:hidden">          {chapters.map((c, i) => (            <span key={c.id} className="h-[3px] flex-1 overflow-hidden rounded-full bg-(--color-rule)">              <motion.span                className="block h-full origin-left rounded-full bg-(--color-ink)"                initial={false}                animate={{ scaleX: i <= current ? 1 : 0 }}                transition={reduce ? { duration: 0 } : { duration: 0.4, ease: EASE_OUT }}              />            </span>          ))}        </div>
        <div          ref={scrollRef}          role="region"          aria-label="Story"          tabIndex={0}          className={cn(            "relative snap-y snap-mandatory overflow-y-auto overscroll-contain rounded-[20px] bg-(--color-paper) outline-none",            "shadow-[0_0_0_1px_var(--color-rule),0_1px_2px_rgba(0,0,0,0.04),0_16px_40px_-20px_rgba(0,0,0,0.2)]",            "[scrollbar-width:none] focus-visible:ring-2 focus-visible:ring-(--color-focus) [&::-webkit-scrollbar]:hidden",          )}          style={{ height }}        >          {chapters.map((chapter, index) => (            <Chapter key={chapter.id} chapter={chapter} index={index} container={scrollRef} reduce={reduce} />          ))}        </div>
        {/* Scroll hint */}        {!reduce ? (          <motion.div            aria-hidden            style={{ opacity: hintOpacity }}            className="pointer-events-none absolute bottom-4 left-6 flex items-center gap-1.5 text-[11.5px] font-medium text-(--color-neutral) sm:left-8"          >            Scroll            <motion.svg              viewBox="0 0 16 16"              className="size-3.5"              fill="none"              stroke="currentColor"              strokeWidth="1.8"              strokeLinecap="round"              strokeLinejoin="round"              animate={{ y: [0, 3, 0] }}              transition={{ duration: 1.4, repeat: Infinity, ease: "easeInOut" }}            >              <path d="M4 6l4 4 4-4" />            </motion.svg>          </motion.div>        ) : null}
        {/* Counter and step buttons */}        <div className="absolute right-3 bottom-3 flex items-center gap-1 rounded-full bg-(--color-paper)/80 p-1 pl-3 shadow-[0_0_0_1px_var(--color-rule),0_4px_12px_-6px_rgba(0,0,0,0.2)] backdrop-blur-md">          <span className="flex items-center font-mono text-[11.5px] tabular-nums text-(--color-neutral)">            <span className="relative inline-grid overflow-hidden text-(--color-ink)">              <AnimatePresence initial={false} mode="popLayout">                <motion.span                  key={current}                  initial={reduce ? { opacity: 0 } : { y: 10, opacity: 0, filter: "blur(2px)" }}                  animate={{ y: 0, opacity: 1, filter: "blur(0px)" }}                  exit={reduce ? { opacity: 0 } : { y: -10, opacity: 0, filter: "blur(2px)" }}                  transition={{ duration: 0.3, ease: EASE_OUT }}                >                  {String(current + 1).padStart(2, "0")}                </motion.span>              </AnimatePresence>            </span>            <span className="ml-1">/ {String(count).padStart(2, "0")}</span>          </span>          <StepButton label="Previous chapter" disabled={current === 0} onClick={() => jumpTo(current - 1)} d="M4 10l4-4 4 4" />          <StepButton label="Next chapter" disabled={current === count - 1} onClick={() => jumpTo(current + 1)} d="M4 6l4 4 4-4" />        </div>      </div>    </div>  );}
function StepButton({ label, disabled, onClick, d }: { label: string; disabled: boolean; onClick: () => void; d: string }) {  return (    <button      type="button"      aria-label={label}      disabled={disabled}      onClick={onClick}      className="grid size-7 place-items-center rounded-full text-(--color-ink-2) outline-none transition-[background-color,color,opacity,transform] hover:bg-(--color-paper-2) hover:text-(--color-ink) active:scale-90 focus-visible:ring-2 focus-visible:ring-(--color-focus) disabled:pointer-events-none disabled:opacity-30"    >      <svg aria-hidden viewBox="0 0 16 16" className="size-3.5" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">        <path d={d} />      </svg>    </button>  );}
function Chapter({  chapter,  index,  container,  reduce,}: {  chapter: StoryChapter;  index: number;  container: RefObject<HTMLDivElement | null>;  reduce: boolean;}) {  const ref = useRef<HTMLElement>(null);  // 0 → entering from below, 0.5 → centred, 1 → leaving above.  const { scrollYProgress: p } = useScroll({ container, target: ref, offset: ["start end", "end start"] });  const opacity = useTransform(p, [0.1, 0.42, 0.58, 0.9], [0, 1, 1, 0]);  const y = useTransform(p, [0, 0.5, 1], [48, 0, -48]);  const blur = useTransform(p, [0.15, 0.42, 0.58, 0.85], [8, 0, 0, 8]);  const filter = useTransform(blur, (b) => `blur(${b}px)`);  const visualY = useTransform(p, [0, 0.5, 1], [80, 0, -80]);  const visualScale = useTransform(p, [0.1, 0.5, 0.9], [0.86, 1, 0.86]);  const visualRotate = useTransform(p, [0, 1], [-6, 6]);
  return (    <article      ref={ref}      data-story-index={index}      aria-labelledby={`story-${chapter.id}`}      className="relative grid min-h-full snap-start items-center gap-6 overflow-hidden p-6 pb-16 sm:grid-cols-[minmax(0,1fr)_auto] sm:p-8 sm:pb-16"    >      <motion.div style={reduce ? undefined : { opacity, y, filter }}>        <div className="text-[11px] font-semibold tracking-[0.14em] text-(--color-neutral) uppercase">          {chapter.kicker ?? `Chapter ${String(index + 1).padStart(2, "0")}`}        </div>        <div          id={`story-${chapter.id}`}          role="heading"          aria-level={3}          className="mt-3 font-(family-name:--font-display) text-[clamp(1.6rem,4vw,2.1rem)] leading-[1.05] font-semibold tracking-[-0.02em] text-balance text-(--color-ink)"        >          {chapter.title}        </div>        <div className="mt-3 max-w-md text-[14.5px] leading-relaxed text-pretty text-(--color-ink-2)">{chapter.body}</div>      </motion.div>      {chapter.visual ? (        <motion.div          aria-hidden          style={reduce ? undefined : { y: visualY, scale: visualScale, rotate: visualRotate, opacity }}          className="justify-self-center"        >          {chapter.visual}        </motion.div>      ) : null}    </article>  );}

Adjust import paths according to your folder structure.


Basic Usage

tsx
import { StoryScroller } from "@/components/ui/story-scroller";
<StoryScroller  chapters={[    { id: "notice", title: "Notice", body: "Start with a small friction point.", visual: <NoticeArt /> },    { id: "make", title: "Make", body: "Turn the insight into something people can try." },  ]}  onChapterChange={(i) => track("chapter", i)}/>

Props

PropTypeRequiredDefaultDescription
chapters{ id, title, body, kicker?, visual? }[]Yes—Chapters in order. kicker replaces the Chapter 01 label.
onChapterChange(index: number) => voidNo—Fires when a new chapter becomes current.
heightstringNo"min(26rem, 70vh)"Height of the story panel.
classNamestringNo—Classes for the wrapper.

Best Practices

  1. Keep each chapter to a title and two or three sentences; it should fit the panel without scrolling inside a chapter.
  2. Visuals work best as simple shapes or illustrations about 140px square.
  3. The panel snaps to chapters and is focusable, so arrow keys and Page Up/Down scroll it.
  4. Reduced motion turns off the blur, drift and smooth scrolling.