Skip to content
goodtabs UI
BrowseBrowse components

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

PropTypeDefaultDescription
pacenumber0.9Scroll distance per step on PinnedStory, as a multiple of the viewport height.
eyebrowstringShort mono label on PinnedStoryStep.
titleReactNodeThe step's heading, required on PinnedStoryStep.
descriptionReactNodeOne grey line under the title.
childrenReactNodeThe 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.