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 motionAdd 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:
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
| Prop | Type | Required | Default | Description |
|---|---|---|---|---|
| chapters | { id, title, body, kicker?, visual? }[] | Yes | — | Chapters in order. kicker replaces the Chapter 01 label. |
| onChapterChange | (index: number) => void | No | — | Fires when a new chapter becomes current. |
| height | string | No | "min(26rem, 70vh)" | Height of the story panel. |
| className | string | No | — | Classes for the wrapper. |
Best Practices
- Keep each chapter to a title and two or three sentences; it should fit the panel without scrolling inside a chapter.
- Visuals work best as simple shapes or illustrations about 140px square.
- The panel snaps to chapters and is focusable, so arrow keys and Page Up/Down scroll it.
- Reduced motion turns off the blur, drift and smooth scrolling.