Pinned story
A section that pins and walks through its steps as you scroll, a stacked list on phones.
Installs gsap from npm, under GSAP's own licence. No GSAP code ships in this registry.
npx shadcn@latest add https://ui.goodtabs.co/r/pinned-story.json
With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/pinned-story
Three steps with a progress hairline. Scroll inside the frame to walk through them.
Usage
import { PinnedStory, PinnedStoryStep } from "@/components/ui/pinned-story"
<PinnedStory aria-label="How it works">
<PinnedStoryStep eyebrow="Step 01" title="Collect" description="One line on the step.">
<img src="/step-one.png" alt="" />
</PinnedStoryStep>
</PinnedStory>Props
| Prop | Type | Default | Description |
|---|---|---|---|
| pace | number | 0.9 | Scroll distance per step on PinnedStory, as a multiple of the viewport height. |
| eyebrow | string | Short mono label on PinnedStoryStep. | |
| title | ReactNode | The step's heading, required on PinnedStoryStep. | |
| description | ReactNode | One grey line under the title. | |
| children | ReactNode | The visual on the right of the step. |
Source
pinned-story/pinned-story.tsx
"use client";
import * as React from "react";
import { useGSAP } from "@gsap/react";
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import { cn } from "@/lib/utils";
gsap.registerPlugin(useGSAP, ScrollTrigger);
const STAGE = "(min-width: 768px) and (prefers-reduced-motion: no-preference)";
type PinnedStoryProps = React.ComponentProps<"section"> & {
/** Scroll distance per step, as a multiple of the viewport height. */
pace?: number;
};
/**
* A section that pins and walks through its steps as you scroll. On phones and under reduced motion
* the steps are a plain stacked list, so the layout below md is the still state and needs no script.
*/
function PinnedStory({ pace = 0.9, className, children, ...props }: PinnedStoryProps) {
const root = React.useRef<HTMLElement>(null);
const total = React.Children.count(children);
useGSAP(
() => {
const media = gsap.matchMedia();
media.add(STAGE, () => {
const steps = gsap.utils.toArray<HTMLElement>("[data-slot=pinned-story-step]");
if (steps.length < 2) return;
const copy = steps.map((s) => s.querySelector("[data-slot=pinned-story-copy]"));
const visual = steps.map((s) => s.querySelector("[data-slot=pinned-story-visual]"));
const timeline = gsap.timeline({
defaults: { ease: "none" },
scrollTrigger: {
trigger: root.current,
pin: true,
start: "top top",
end: () => `+=${(steps.length - 1) * pace * window.innerHeight}`,
scrub: true,
},
});
gsap.set(steps.slice(1), { visibility: "visible" });
gsap.set(copy.slice(1), { autoAlpha: 0, y: 24 });
gsap.set(visual.slice(1), { autoAlpha: 0 });
steps.slice(1).forEach((_, i) => {
// Rest for 0.4, then 0.6 of hand-over. Copy goes one after the other so two headings never
// overlap; visuals cross-fade, so there is always something in the frame.
timeline
.to({}, { duration: 0.4 })
.to(copy[i], { autoAlpha: 0, y: -24, duration: 0.3 })
.to(visual[i], { autoAlpha: 0, duration: 0.6 }, "<")
.to(visual[i + 1], { autoAlpha: 1, duration: 0.6 }, "<")
.to(copy[i + 1], { autoAlpha: 1, y: 0, duration: 0.3 }, ">-0.3");
});
timeline.to({}, { duration: 0.4 });
timeline.fromTo(
"[data-slot=pinned-story-progress]",
{ scaleY: 1 / steps.length },
{ scaleY: 1, duration: timeline.duration(), ease: "none" },
0,
);
});
return () => media.revert();
},
{ scope: root, dependencies: [pace, total] },
);
return (
<section
ref={root}
data-slot="pinned-story"
className={cn("relative bg-background md:motion-safe:h-dvh md:motion-safe:overflow-hidden", className)}
{...props}
>
<div className="mx-auto flex h-full w-full max-w-6xl gap-8 px-6 py-16 md:px-8 md:motion-safe:py-0">
<div aria-hidden="true" className="relative hidden w-px bg-border md:motion-safe:my-20 md:motion-safe:block">
<div
data-slot="pinned-story-progress"
className="absolute inset-0 origin-top bg-foreground"
style={{ transform: `scaleY(${1 / Math.max(total, 1)})` }}
/>
</div>
<div className="relative flex min-w-0 flex-1 flex-col gap-16 md:motion-safe:block">{children}</div>
</div>
</section>
);
}
type PinnedStoryStepProps = Omit<React.ComponentProps<"div">, "title"> & {
/** Short mono label above the title, such as a step number. */
eyebrow?: string;
title: React.ReactNode;
description?: React.ReactNode;
};
/** One step: text on the left, whatever you pass as children on the right. */
function PinnedStoryStep({ eyebrow, title, description, className, children, ...props }: PinnedStoryStepProps) {
return (
<div
data-slot="pinned-story-step"
className={cn(
"grid items-center gap-8 md:grid-cols-2 md:gap-16 md:motion-safe:absolute md:motion-safe:inset-0 md:motion-safe:not-first:invisible",
className,
)}
{...props}
>
<div data-slot="pinned-story-copy" className="min-w-0">
{eyebrow ? (
<p className="m-0 font-mono text-xs tracking-wide text-muted-foreground uppercase">{eyebrow}</p>
) : null}
<h3 className="mt-3 mb-0 text-3xl leading-none font-medium tracking-tight md:text-5xl">{title}</h3>
{description ? (
<p className="mt-4 mb-0 max-w-xl text-base text-muted-foreground md:text-lg">{description}</p>
) : null}
</div>
<div data-slot="pinned-story-visual" className="min-w-0">
{children}
</div>
</div>
);
}
export { PinnedStory, PinnedStoryStep };
For AI agents
Hand your agent this page, the markdown at /components/pinned-story/prompt.md, or the Copy AI prompt button above.
When to use
- Explaining a process of three to five steps where order matters
- A product walkthrough with one visual per step
- Once on a landing page, as the section people remember
When not to use
- For reference content people need to scan: use a list or tabs
- More than once on a page, because two pins back to back feel like a trap
- Inside a scaled or transformed parent, which breaks ScrollTrigger's measuring
Agent notes
- GSAP installs from npm under GSAP's own licence. No GSAP code ships in this registry
- It pins against the window, so it must not sit inside an overflow container or a CSS transform
- Below 768px and under reduced motion there is no pin: steps are a plain stacked list
- Give PinnedStory an aria-label, because it renders a section landmark
The maths
- Total pin distance is (n - 1) × pace × viewport height, so three steps at 0.9 scroll 1.8 screens.
- Each leg rests for 0.4, then copy hands over in sequence while visuals cross-fade for 0.6, so no frame is empty.
- The progress hairline scales from 1 / n to 1 with transform, so it never reflows the steps.