A scroll-story landing page in React: one pinned stage, one clock
How to build a pinned scroll story in React with Motion: sticky positioning, one scroll-driven clock, and a single object that changes state scene by scene.
8 min read
Most landing pages are sections: a hero, then three feature boxes, then pricing. Each box fades up as it enters and that is the whole experience. The pages that win design awards work differently. They are a few scenes. A scene holds the screen while scrolling plays it like a film, and one object stays on screen for the whole story, changing state.
We rebuilt our deploy-platform template this way. The customer's site sits in a browser window, pinned. Scrolling builds it, splits it into branch previews, multiplies it into sixteen instances under traffic, and rewinds it after a bad deploy. This is how it is put together, with no scroll library beyond Motion.
Pin with sticky, not with JavaScript
The pin is plain CSS. The section holds a stage that is position: sticky, top: 0 and one screen tall, followed by five empty spacers that are each one screen tall. While the spacers scroll past, the stage stays put. There is no scroll listener moving anything and nothing to get out of sync.
The spacers also solve navigation. Each one holds an anchor at its halfway point, so a link to #previews lands exactly where that scene has come to rest.
src/registry/landing-pages/slipway/scroll-story.tsx
"use client";
import { useRef } from "react";
import { animate, useScroll, useTransform } from "motion/react";
import { usePrefersReducedMotion } from "@/hooks/use-prefers-reduced-motion";
import { EASE_IN_OUT } from "@/lib/motion";
import { DeployField } from "./deploy-field";
import { LetterReveal } from "./letter-reveal";
import { RunCommand } from "./run-command";
import { SceneCopy } from "./scene-copy";
import { StoryRail } from "./story-rail";
import { StoryReadout } from "./story-readout";
import { StoryStage } from "./story-stage";
import { COPY_SPANS, REST_OFFSET, SCENE_COUNT } from "./story-timeline";
import type { SlipwayContent } from "./types";
// How long Run takes to scroll the page through the build, in seconds.
const RUN_SECONDS = 2.4;
const LINK = "underline decoration-slip-faint underline-offset-4 hover:text-slip-fg hover:decoration-signal focus-visible:outline-2 focus-visible:outline-slip-fg";
interface ScrollStoryProps {
story: SlipwayContent["story"];
people: SlipwayContent["people"];
}
// The page as one pinned scene. The stage holds the screen for five screens of scrolling, and the scroll is the
// clock: the copy on the left changes scene by scene while the site window on the right builds, splits into
// previews, multiplies under traffic and rewinds. Run in the hero scrolls the page through the build by itself.
export function ScrollStory({ story, people }: ScrollStoryProps) {
const ref = useRef<HTMLElement>(null);
const isReduced = usePrefersReducedMotion();
const { scrollYProgress } = useScroll({ target: ref, offset: ["start start", "end end"] });
const time = useTransform(scrollYProgress, [0, 1], [0, SCENE_COUNT]);
const { hero, scenes } = story;
const runDeploy = () => {
if (!ref.current) return;
const target = ref.current.getBoundingClientRect().top + window.scrollY + window.innerHeight * REST_OFFSET;
const scrollTo = (top: number) => window.scrollTo({ top, behavior: "instant" });
if (isReduced) return scrollTo(target);
animate(window.scrollY, target, { duration: RUN_SECONDS, ease: EASE_IN_OUT, onUpdate: scrollTo });
};
return (
<section ref={ref} aria-labelledby="slipway-hero" className="relative -mt-16 font-plex">
<div className="sticky top-0 isolate flex h-svh flex-col overflow-hidden pt-16">
<DeployField className="absolute inset-0 -z-10 opacity-60 mask-b-from-40% mask-l-from-0% mask-l-to-70%" />
<div className="mx-auto grid w-full max-w-7xl flex-1 grid-cols-1 content-start gap-8 px-5 pt-6 lg:grid-cols-12 lg:content-center lg:gap-12 lg:px-8 lg:pt-0">
<div className="grid items-start lg:col-span-5">
<SceneCopy
time={time}
span={COPY_SPANS[0]}
heading="h1"
id="slipway-hero"
title={<LetterReveal text={hero.title} />}
subtitle={<LetterReveal text={hero.subtitle} delay={0.35} />}
description={hero.description}
>
<RunCommand command={hero.command} action={hero.run} onRun={runDeploy} />
<p className="flex gap-6 text-sm text-slip-muted max-sm:hidden">
{[hero.primary, hero.secondary].map((link) => (
<a key={link.label} href={link.href} className={LINK}>
{link.label}
</a>
))}
</p>
</SceneCopy>
{scenes.map((scene, index) => (
<SceneCopy key={scene.id} time={time} span={COPY_SPANS[index + 1]} heading="h2" title={scene.title} subtitle={scene.subtitle} description={scene.description} />
))}
</div>
<div className="lg:col-span-7">
<StoryStage time={time} story={story} people={people} />
<StoryReadout time={time} story={story} />
</div>
</div>
<StoryRail
time={time}
label={story.railLabel}
steps={[{ label: hero.label, href: "#" }, ...scenes.map((scene) => ({ label: scene.label, href: `#${scene.id}` }))]}
className="mx-auto w-full max-w-7xl px-5 pb-6 max-lg:hidden lg:px-8"
/>
</div>
{Array.from({ length: SCENE_COUNT }, (_, index) => (
<div key={index} className="relative h-svh">
{scenes[index] && <span id={scenes[index].id} className="absolute top-1/2" />}
</div>
))}
</section>
);
}
One clock for everything
useScroll gives a progress value from 0 to 1 across the section. We map that to a clock that runs from 0 to 5, where scrolling one screen advances it by 1. Every moving part reads that one clock with useTransform.
All the timing lives in one small file of named moments: when the build starts and goes live, when the previews fan out, when the surge peaks, when the rollback rewinds. Nothing else in the page contains a magic number, and retiming the whole story is an edit to one file.
src/registry/landing-pages/slipway/story-timeline.ts
// The story's clock. Scrolling one screen moves it by 1; the stage is pinned from 0 to SCENE_COUNT. Each scene comes to
// rest at its number plus a half (build at 1.5, previews at 2.5โฆ), which is where the nav's links land.
export const SCENE_COUNT = 5;
export const REST_OFFSET = 1.5;
// How long a block of copy takes to arrive or leave.
export const COPY_FADE = 0.12;
// When each scene's copy is on screen: push, build, previews, scale, rollbacks.
export const COPY_SPANS: [number, number][] = [
[-1, 0.5],
[0.62, 1.6],
[1.72, 2.64],
[2.76, 3.74],
[3.86, SCENE_COUNT + 1],
];
// Build: the new version wipes down over the old one, then goes live.
export const BUILD = { start: 0.65, live: 1.4, end: 1.65 };
// Previews: two branch copies fan out, then one slides onto production.
export const PREVIEWS = { start: 1.7, fanned: 2, promote: 2.2, promoted: 2.45 };
// Surge: the window shrinks into the first cell of a four by four grid, and the other instances start around it.
export const SURGE = { start: 2.8, tile: 3.1, full: 3.4, hold: 3.65, end: 3.9 };
export const TILE_SCALE = 0.235;
export const TILE_SHIFT = "-37.5%";
export const TILE_STAGGER = 0.018;
export const TILE_FADE = 0.15;
// Rollback: errors climb, the site rewinds to the last good version, errors fall.
export const ROLLBACK = { start: 3.95, peak: 4.2, rewind: 4.3, rewound: 4.5, calm: 4.7 };
The important part is that the clock is a MotionValue, not React state. Scrolling never re-renders a component. Motion writes transforms straight to the elements, which is why a page with this much movement stays smooth.
One object, many states
The site window is the page's one object. Its scale, position and contents are each a useTransform over the clock, with keyframes taken from the timeline file.
- Build: the new version of the site is stacked over the old one and revealed top to bottom by animating clip-path, behind a green scan line.
- Previews: the window scales down while two more windows travel out from behind it. One then scales up into the middle and takes its place.
- Scale: the window shrinks into the first cell of a four-by-four grid, and fifteen instance tiles appear around it, one after another.
- Roll back: the promoted version is wiped away right to left, which reads as a rewind.
src/registry/landing-pages/slipway/site-window.tsx
"use client";
import { motion, useTransform, type MotionValue } from "motion/react";
import { MiniSite } from "./mini-site";
import { BUILD, PREVIEWS, ROLLBACK, SURGE, TILE_SCALE, TILE_SHIFT } from "./story-timeline";
import type { SlipwayContent } from "./types";
import { WindowChrome } from "./window-chrome";
const CLOSED_DOWN = "inset(0% 0% 100% 0%)";
const OPEN = "inset(0% 0% 0% 0%)";
const CLOSED_LEFT = "inset(0% 100% 0% 0%)";
// How long the promoted branch takes to settle into the production window.
const HANDOVER = 0.08;
interface SiteWindowProps {
time: MotionValue<number>;
story: SlipwayContent["story"];
}
// The page's one object: the production site in its window. Scroll builds the new version down over the old one,
// shrinks it while branches fan out, swaps in the promoted branch, shrinks it into one instance among sixteen, and
// rewinds it when the promoted version goes wrong.
export function SiteWindow({ time, story }: SiteWindowProps) {
const { site, versions, status } = story;
const scale = useTransform(
time,
[PREVIEWS.start, PREVIEWS.fanned, PREVIEWS.promote, PREVIEWS.promoted, SURGE.start, SURGE.tile, SURGE.hold, SURGE.end],
[1, 0.6, 0.6, 1, 1, TILE_SCALE, TILE_SCALE, 1],
);
const shift = useTransform(time, [SURGE.start, SURGE.tile, SURGE.hold, SURGE.end], ["0%", TILE_SHIFT, TILE_SHIFT, "0%"]);
const built = useTransform(time, [BUILD.start, BUILD.live], [CLOSED_DOWN, OPEN]);
const scan = useTransform(time, [BUILD.start, BUILD.live], ["-100%", "0%"]);
const isBuilding = useTransform(time, (at) => (at > BUILD.start && at < BUILD.live ? 1 : 0));
const promoted = useTransform(time, [PREVIEWS.promoted - HANDOVER, PREVIEWS.promoted], [0, 1]);
const rewound = useTransform(time, [ROLLBACK.rewind, ROLLBACK.rewound], [OPEN, CLOSED_LEFT]);
const isRewinding = (at: number) => at >= ROLLBACK.rewind && at < ROLLBACK.rewound;
// Stepped, not faded: the frame and its light switch on the moment the deploy is live.
const live = useTransform(time, (at) => (at >= BUILD.live && !isRewinding(at) ? 1 : 0));
const statusLabel = useTransform(time, (at) => {
if (at < BUILD.start) return status.queued;
if (at < BUILD.live) return status.building;
return isRewinding(at) ? status.rollingBack : status.live;
});
const version = useTransform(time, (at) => {
if (at < BUILD.live) return versions.before;
return at >= PREVIEWS.promoted && at < ROLLBACK.rewound ? versions.promoted : versions.after;
});
return (
<motion.div style={{ scale, x: shift, y: shift }} className="absolute inset-0">
<WindowChrome
address={story.domain}
aside={
<>
<motion.span className="tabular-nums">{version}</motion.span>
<span className="relative size-1.5 rounded-full bg-slip-faint">
<motion.span style={{ opacity: live }} className="absolute inset-0 rounded-full bg-signal" />
</span>
<motion.span className="text-slip-fg">{statusLabel}</motion.span>
</>
}
>
<MiniSite headline={site.before} className="absolute inset-0 opacity-70 grayscale" />
<motion.div style={{ clipPath: built }} className="absolute inset-0">
<MiniSite headline={site.after} className="size-full" />
</motion.div>
<motion.div aria-hidden style={{ y: scan, opacity: isBuilding }} className="absolute inset-0 border-b-2 border-signal" />
<motion.div style={{ opacity: promoted, clipPath: rewound }} className="absolute inset-0">
<MiniSite headline={story.previews[0].headline} className="size-full" />
</motion.div>
</WindowChrome>
<motion.div aria-hidden style={{ opacity: live }} className="pointer-events-none absolute inset-0 rounded-lg ring-1 ring-signal" />
</motion.div>
);
}
Some things should not ease. The green frame around a live deploy and the status label switch on at a moment, like a light. Those use a function form of useTransform that returns 0 or 1, with no interpolation.
Words that arrive with the scroll
The copy for all five scenes sits in the same grid cell. Each block fades in over a short stretch of the clock while its two headline lines rise from under a clip, then fades out for the next. Because every block is in the DOM, the page's full text is there for search engines and screen readers from the start.
src/registry/landing-pages/slipway/scene-copy.tsx
"use client";
import type { ReactNode } from "react";
import { motion, useTransform, type MotionValue } from "motion/react";
import { cn } from "@/lib/utils";
import { COPY_FADE } from "./story-timeline";
// The headline's lines climb a little later than the block fades in, so the type reads as arriving, not appearing.
const LINE_LAG = 1.6;
interface SceneCopyProps {
time: MotionValue<number>;
// When this block is on screen, on the story's clock.
span: [number, number];
heading: "h1" | "h2";
id?: string;
title: ReactNode;
subtitle: ReactNode;
description: string;
children?: ReactNode;
}
// One scene's words in the pinned copy column. Every scene's block sits in the same grid cell; scroll fades the
// current one in while its two headline lines rise out from under a clip, then fades it out for the next.
export function SceneCopy({ time, span, heading: Heading, id, title, subtitle, description, children }: SceneCopyProps) {
const [from, to] = span;
const opacity = useTransform(time, [from, from + COPY_FADE, to, to + COPY_FADE], [0, 1, 1, 0]);
const rise = useTransform(time, [from, from + COPY_FADE * LINE_LAG], ["110%", "0%"]);
const pointerEvents = useTransform(opacity, (shown) => (shown > 0.5 ? "auto" : "none"));
return (
<motion.div style={{ opacity, pointerEvents }} className="col-start-1 row-start-1 flex flex-col gap-4 sm:gap-6">
<Heading id={id} className="text-4xl leading-none font-medium tracking-tighter sm:text-6xl lg:text-5xl xl:text-6xl">
{[title, subtitle].map((line, index) => (
<span key={index} className="block overflow-hidden pb-1.5">
<motion.span style={{ y: rise }} className={cn("block", index === 1 && "text-slip-faint")}>
{line}
</motion.span>
</span>
))}
</Heading>
<p className="max-w-md text-sm text-pretty text-slip-muted sm:text-lg">{description}</p>
{children}
</motion.div>
);
}
Let the hero run the story
The hero shows the deploy command with a Run button. Pressing it does not play a separate animation. It scrolls the page through the build, by animating window.scrollTo over about two and a half seconds. The visitor sees the story start, and also learns that scrolling is the control.
Things to watch
- Use svh for the stage height, so mobile browser bars do not make it jump.
- Animate transforms, opacity and clip-path only. Anything that changes layout will stutter when driven by scroll.
- Keep readouts that change with the scroll in tabular numerals, or the digits shift sideways.
- Scroll-linked motion is driven by the visitor, so it is generally acceptable under reduced motion, but skip any motion you start yourself, such as the Run button's automatic scroll.
- On a phone, stack the copy above the stage inside the same pinned screen and hide what does not fit.
More articles

7 min read
How we built a login page where the password cuts a keyA React login page that turns the password into a brass key as you type: the hash behind the cuts, the SVG that animates them, and the mistakes we made first.
5 min read
OTP input in React: use one real input, not sixSix separate boxes break paste, autofill and screen readers. Build a six-digit code input in React from a single input with the boxes drawn underneath it.
