Scroll-driven storytelling: text steps scroll past while one visual stays pinned and crossfades to match, with each visual inline on small screens.
Plan the work together
Turn a goal into tasks, owners and dates. Everyone sees the same plan, and changes show up for the whole team as they happen.Talk where the work is
Comment on a task, a file or a single line. Mentions notify the right person, and the conversation stays attached to the work.Know how it went
See what shipped, what slipped and where time went. Reports update on their own, so the review starts with facts.
"use client";
import * as React from "react";
import { BarChart3, CheckCircle2, MessageSquare } from "lucide-react";
import { StickyScroll } from "@/components/ballmac/sticky-scroll";
function Panel({ icon: Icon, tone, children }: { icon: typeof BarChart3; tone: string; children: React.ReactNode }) {
return (
<div className={`flex h-full flex-col justify-between bg-gradient-to-br ${tone} via-card to-card p-6`}>
<span className="flex size-12 items-center justify-center rounded-2xl border bg-background shadow-sm">
<Icon aria-hidden="true" className="size-6" />
</span>
<div className="grid gap-2">{children}</div>
</div>
);
}
function Bar({ w, label }: { w: string; label: string }) {
return (
<div className="grid gap-1">
<div className="flex justify-between text-xs text-muted-foreground"><span>{label}</span><span className="tabular-nums">{w}</span></div>
<div className="h-2 rounded-full bg-muted"><div className="h-full rounded-full bg-primary" style={{ width: w }} /></div>
</div>
);
}
const items = [
{
id: "plan",
title: "Plan the work together",
description: "Turn a goal into tasks, owners and dates. Everyone sees the same plan, and changes show up for the whole team as they happen.",
visual: (
<Panel icon={CheckCircle2} tone="from-chart-2/20">
<Bar w="72%" label="Launch plan" />
<Bar w="45%" label="Design review" />
<Bar w="90%" label="Copy edit" />
</Panel>
),
},
{
id: "talk",
title: "Talk where the work is",
description: "Comment on a task, a file or a single line. Mentions notify the right person, and the conversation stays attached to the work.",
visual: (
<Panel icon={MessageSquare} tone="from-chart-1/20">
<div className="w-4/5 rounded-2xl rounded-es-sm border bg-background p-3 text-sm shadow-sm">Can we move the review to Thursday?</div>
<div className="ms-auto w-3/5 rounded-2xl rounded-ee-sm bg-primary p-3 text-sm text-primary-foreground shadow-sm">Done, invites updated.</div>
</Panel>
),
},
{
id: "measure",
title: "Know how it went",
description: "See what shipped, what slipped and where time went. Reports update on their own, so the review starts with facts.",
visual: (
<Panel icon={BarChart3} tone="from-chart-3/20">
<div className="flex h-28 items-end gap-2">
{[40, 65, 52, 80, 70, 95].map((h, i) => (
<div key={i} className="flex-1 rounded-t-md bg-primary/80" style={{ height: `${h}%` }} />
))}
</div>
<p className="text-xs text-muted-foreground">Tasks completed, last six weeks</p>
</Panel>
),
},
];
export default function StickyScrollDemo() {
const scroller = React.useRef<HTMLDivElement>(null);
return (
<div ref={scroller} role="region" tabIndex={0} aria-label="Feature story" className="outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 h-[22rem] w-full max-w-4xl overflow-auto rounded-xl border bg-background p-6 shadow-sm sm:p-8">
<StickyScroll items={items} container={scroller} stickyOffset={8} stepMinHeight="20rem" className="lg:gap-12" />
</div>
);
}Installation
$ pnpm dlx shadcn@latest add @ballmac/sticky-scrollInstall the dependencies.
$ pnpm add motion@^12Add the Ballmac items it builds on.
$ pnpm dlx shadcn@latest add @ballmac/motion-presetsCopy the source into your project.
components/ballmac/sticky-scroll.tsx// Ballmac UI: Sticky Scroll. https://ui.ballmac.com/components/sticky-scroll "use client"; import * as React from "react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { ease } from "@/lib/ballmac/motion"; import { cn } from "@/lib/utils"; type StickyScrollItem = { /** Unique id of the step. */ id: string; /** Step heading. */ title: string; /** Step text. */ description: React.ReactNode; /** The visual shown in the sticky panel while this step is active. */ visual: React.ReactNode; }; type StickyScrollProps = Omit<React.ComponentProps<"div">, "children"> & { /** Steps, in reading order. */ items: StickyScrollItem[]; /** Which side the sticky visual sits on from the `lg` breakpoint up. */ visualSide?: "left" | "right"; /** Distance from the top of the viewport where the sticky visual stops, in pixels. */ stickyOffset?: number; /** Called when the active step changes. */ onActiveChange?: (id: string) => void; /** A scrollable element that contains the steps, when the page itself does not scroll (panels, previews). */ container?: React.RefObject<HTMLElement | null>; /** Minimum height of each step from `lg` up. Longer steps give the reader more time on each visual. */ stepMinHeight?: string; }; /** * Scroll-driven storytelling: text steps scroll past while one visual stays pinned and crossfades to match the step. * Below `lg` each step shows its own visual inline, so nothing is hidden and nothing is pinned. */ function StickyScroll({ items, visualSide = "right", stickyOffset = 96, onActiveChange, container, stepMinHeight = "70svh", className, ...props }: StickyScrollProps) { const reduce = useReducedMotion(); const [activeId, setActiveId] = React.useState(items[0]?.id); const stepRefs = React.useRef(new Map<string, HTMLElement>()); const callback = React.useRef(onActiveChange); React.useEffect(() => { callback.current = onActiveChange; }); React.useEffect(() => { const steps = [...stepRefs.current.entries()]; if (!steps.length) return; const observer = new IntersectionObserver( (entries) => { const visible = entries.filter((e) => e.isIntersecting); if (!visible.length) return; const best = visible.reduce((a, b) => (b.intersectionRatio > a.intersectionRatio ? b : a)); const id = steps.find(([, el]) => el === best.target)?.[0]; if (id) { setActiveId(id); callback.current?.(id); } }, { root: container?.current ?? null, rootMargin: "-45% 0px -45% 0px", threshold: [0, 0.25, 0.5, 0.75, 1] }, ); steps.forEach(([, el]) => observer.observe(el)); return () => observer.disconnect(); }, [items, container]); const active = items.find((i) => i.id === activeId) ?? items[0]; return ( <div data-slot="sticky-scroll" className={cn("grid gap-10 lg:grid-cols-2 lg:gap-16", className)} {...props} > <ol className={cn("grid gap-16 lg:gap-0", visualSide === "left" && "lg:order-2")}> {items.map((item) => { const isActive = item.id === active?.id; return ( <li key={item.id} ref={(el) => { if (el) stepRefs.current.set(item.id, el); else stepRefs.current.delete(item.id); }} aria-current={isActive ? "step" : undefined} data-active={isActive || undefined} style={{ "--step-min-h": stepMinHeight } as React.CSSProperties} className="group/step relative grid content-center gap-4 lg:min-h-(--step-min-h) lg:border-s-2 lg:border-transparent lg:ps-6 lg:transition-colors lg:data-[active]:border-primary motion-reduce:transition-none" > <div className="lg:hidden" aria-hidden="true"> <div className="aspect-[4/3] overflow-hidden rounded-2xl border bg-card">{item.visual}</div> </div> <h3 className="text-2xl font-semibold tracking-tight text-balance transition-colors sm:text-3xl lg:text-muted-foreground lg:group-data-[active]/step:text-foreground motion-reduce:transition-none">{item.title}</h3> <div className="max-w-prose text-base leading-relaxed text-muted-foreground">{item.description}</div> </li> ); })} </ol> <div className={cn("hidden lg:block", visualSide === "left" && "lg:order-1")} aria-hidden="true"> <div className="sticky aspect-[4/3] w-full overflow-hidden rounded-3xl border bg-card shadow-[0_24px_60px_-24px_rgb(0_0_0/0.25)]" style={{ top: stickyOffset }} > <AnimatePresence mode="popLayout" initial={false}> {active && ( <motion.div key={active.id} className="absolute inset-0" initial={reduce ? false : { opacity: 0, scale: 0.98 }} animate={{ opacity: 1, scale: 1 }} exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 1.02 }} transition={{ duration: reduce ? 0 : 0.4, ease: ease.out }} > {active.visual} </motion.div> )} </AnimatePresence> </div> </div> </div> ); } export { StickyScroll, type StickyScrollProps, type StickyScrollItem };Update the import paths to match your project setup.
Usage
import { StickyScroll } from "@/components/ballmac/sticky-scroll"The full example is in the Code tab above.
Examples
Visual on the left
Capture
Step 1. Each step keeps its own visual on the left while you read.Organize
Step 2. Each step keeps its own visual on the left while you read.Share
Step 3. Each step keeps its own visual on the left while you read.
"use client";
import * as React from "react";
import { StickyScroll } from "@/components/ballmac/sticky-scroll";
const items = ["Capture", "Organize", "Share"].map((t, i) => ({
id: t,
title: t,
description: `Step ${i + 1}. Each step keeps its own visual on the left while you read.`,
visual: <div className="grid h-full place-items-center bg-muted text-4xl font-semibold tabular-nums">{i + 1}</div>,
}));
export default function StickyScrollStates() {
const scroller = React.useRef<HTMLDivElement>(null);
return (
<div ref={scroller} role="region" tabIndex={0} aria-label="Steps" className="outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 h-72 w-full max-w-3xl overflow-auto rounded-xl border bg-background p-6">
<StickyScroll items={items} container={scroller} visualSide="left" stickyOffset={8} stepMinHeight="16rem" />
</div>
);
}API reference
| Prop | Type | Default |
|---|---|---|
items*Steps, in reading order. | StickyScrollItem[] | — |
visualSideWhich side the sticky visual sits on from the `lg` breakpoint up. | "left" | "right" | "right" |
stickyOffsetDistance from the top of the viewport where the sticky visual stops, in pixels. | number | 96 |
onActiveChangeCalled when the active step changes. | (id: string) => void | — |
containerA scrollable element that contains the steps, when the page itself does not scroll (panels, previews). | React.RefObject<HTMLElement | null> | — |
stepMinHeightMinimum height of each step from `lg` up. Longer steps give the reader more time on each visual. | string | "70svh" |
Also accepts the standard attributes of its root element.
Accessibility
| Key | Action |
|---|---|
| Reading order | Steps are an ordered list; the pinned visual is decorative and hidden from assistive technology |
| Reduced motion | No crossfade or scale |
Use with AI
items: {id,title,description,visual}. The step nearest the middle of the viewport is active (aria-current=step) and its visual shows in the pinned panel. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Sticky Scroll (@ballmac/sticky-scroll) to this project with the shadcn MCP, then use it where it fits.
Use it for
- Feature tours on landing pages
- Step-by-step explanations with a matching picture
Not for
- Short lists of benefits; use a plain grid
- Content that must all be visible at once
Registry JSON: https://ui.ballmac.com/r/sticky-scroll.json
Credits
Free to use in personal and commercial projects.
- npm
- motion@^12
Pairs well with
Container Scroll
A showcase frame that starts tilted back in 3D and flattens to face the reader as it scrolls into view, with a title that lifts alongside.
Bento Grid
A responsive bento layout of feature cards that span columns and rows. Each BentoCard has a background visual slot, icon, title and description, and a link that slides up on hover or focus.
App Shell
The frame of an application page: skip link, sticky header, sidebar that becomes a sheet on small screens, main area, optional aside and footer.
Aspect Ratio
A CSS-native ratio frame that reserves space for media and safely handles invalid ratio values.