Section
Image Relay
A pinned section where images are promoted through three slots on scroll — waiting, centre stage, then out through the corner — each with its own copy alongside.
01 · Atelier
A frame that never sits still
Each image is promoted through the same three slots — waiting, centre stage, then out through the corner.
01 — 04
What's next
The section that pushes the relay off screen
- 04
- Frames
- 1440
- Design grid
- 0
- Timers
Scroll inside the frame
import { useRef } from "react";
import { ImageRelay, type RelayItem } from "@/components/ui/image-relay";
/** Boxed in the docs; on its own page the section takes the viewport. */
const SCREEN = 420;
const items: RelayItem[] = [
{
image: "https://picsum.photos/seed/relay-01/538/956",
meta: "01 · Atelier",
title: "A frame that never sits still",
description:
"Each image is promoted through the same three slots — waiting, centre stage, then out through the corner.",
},
{
image: "https://picsum.photos/seed/relay-02/538/956",
meta: "02 · Studio",
title: "Scroll drives every position",
description:
"Nothing is on a timer. Scroll back up and the whole relay runs in reverse, frame for frame.",
},
{
image: "https://picsum.photos/seed/relay-03/538/956",
meta: "03 · Field",
title: "Laid out on a 1440 grid",
description:
"Positions are authored at design size and scaled to fit, so the composition holds at any viewport.",
},
{
image: "https://picsum.photos/seed/relay-04/538/956",
meta: "04 · Archive",
title: "Then the page moves on",
description:
"Once the last frame has had its turn the track releases and the next section pushes everything up.",
},
];
export default function ImageRelayDemo({ fullscreen }: { fullscreen?: boolean }) {
const scroller = useRef<HTMLDivElement>(null);
if (fullscreen) {
return (
<ImageRelay items={items} screen="100dvh">
<NextSection height="100dvh" />
</ImageRelay>
);
}
return (
<div className="w-full space-y-3">
<div
ref={scroller}
className="scrollbar-thin w-full min-w-0 overflow-y-auto overscroll-contain rounded-card border border-line"
style={{ height: SCREEN }}
>
<ImageRelay items={items} screen={`${SCREEN}px`} container={scroller}>
<NextSection height={`${SCREEN}px`} />
</ImageRelay>
</div>
<p className="text-center text-xs text-faint">Scroll inside the frame</p>
</div>
);
}
function NextSection({ height }: { height: string }) {
return (
<section
className="flex flex-col justify-center bg-[#0f0f12] px-10 text-white"
style={{ minHeight: height }}
>
<p className="text-xs tracking-[0.2em] text-white/40 uppercase">What's next</p>
<h3 className="mt-4 text-3xl font-semibold tracking-tight text-balance">
The section that pushes the relay off screen
</h3>
<dl className="mt-8 grid grid-cols-3 gap-6 border-t border-white/15 pt-6">
{[
["04", "Frames"],
["1440", "Design grid"],
["0", "Timers"],
].map(([value, label]) => (
<div key={label}>
<dt className="text-2xl font-semibold tabular-nums">{value}</dt>
<dd className="mt-1 text-xs text-white/50">{label}</dd>
</div>
))}
</dl>
</section>
);
}Installation
1. Install the dependencies.
terminal npm install gsap clsx tailwind-merge2. Add the
cnhelper, if you don't already have it.lib/utils.ts import { clsx, type ClassValue } from "clsx"; import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); }3. Copy the component into your project.
components/ui/image-relay.tsx "use client"; import gsap from "gsap"; import { ScrollTrigger } from "gsap/ScrollTrigger"; import { useEffect, useLayoutEffect, useRef, useState, type ReactNode, type RefObject, } from "react"; import { cn } from "@/lib/utils"; export type RelayItem = { image: string; title: string; description: string; /** Small line above the title — a year, a category, a client. */ meta?: string; }; type ImageRelayProps = { items: RelayItem[]; /** Height of one pinned screen. The page default is a viewport tall. */ screen?: string; /** The scrollable ancestor, when this lives inside one instead of the page. */ container?: RefObject<HTMLElement | null>; /** Freeze after each promotion, as a share of one transition's length. */ hold?: number; /** Rendered after the pinned track — the section that pushes the images up. */ children?: ReactNode; className?: string; }; /* Everything below is in the 1440 x 1024 design grid and scaled to fit. */ const GRID = { width: 1440, height: 1024 }; const HERO = { width: 538, height: 956, x: 451, y: 34 }; const SMALL = { width: 175, height: 311 }; /** Hero and small share an aspect ratio, so a scale is all that separates them. */ const SMALL_SCALE = SMALL.width / HERO.width; /** How far below its slot a frame starts, so it rises as it grows in. */ const RISE = 140; /** Timeline units for one promotion. Holds are measured against this. */ const STEP = 1; /** The right column: copy on top, the image waiting its turn underneath. */ const COLUMN = { x: HERO.x + HERO.width + 41, width: 370 }; const COPY = { y: HERO.y, height: 566 }; /** Slot centres, then converted to the offset of a hero-sized box's own centre. */ const slotCentre = { hero: { x: HERO.x + HERO.width / 2, y: HERO.y + HERO.height / 2 }, next: { x: COLUMN.x + SMALL.width / 2, y: 645 + SMALL.height / 2 }, exit: { x: HERO.x - 41 - SMALL.width / 2, y: 68 + SMALL.height / 2 }, }; function offset(slot: { x: number; y: number }) { return { x: slot.x - HERO.width / 2, y: slot.y - HERO.height / 2 }; } const HERO_AT = offset(slotCentre.hero); const NEXT_AT = offset(slotCentre.next); const EXIT_AT = offset(slotCentre.exit); const useIsomorphicLayoutEffect = typeof window !== "undefined" ? useLayoutEffect : useEffect; /** Nearest scrolling ancestor, or null when the page itself is the scroller. */ function findScroller(node: HTMLElement | null) { let parent = node?.parentElement ?? null; while (parent && parent !== document.body) { const overflowY = getComputedStyle(parent).overflowY; if (overflowY === "auto" || overflowY === "scroll") return parent; parent = parent.parentElement; } return null; } export function ImageRelay({ items, screen = "100vh", container, hold = 0.4, children, className, }: ImageRelayProps) { const rootRef = useRef<HTMLDivElement>(null); const trackRef = useRef<HTMLDivElement>(null); const stageRef = useRef<HTMLDivElement>(null); const copyRef = useRef<HTMLDivElement>(null); const frameRefs = useRef<(HTMLElement | null)[]>([]); const [scale, setScale] = useState<number | null>(null); const [active, setActive] = useState(0); const dwell = Math.max(0, hold); // One transition plus its freeze. Every frame moves during the transition // and nothing moves at all during the freeze. const cycle = STEP + dwell; // Passive, not layout: an ancestor's ref is not attached yet while a child's // layout effect runs, so `container.current` would still be null here and // ScrollTrigger would silently bind to the page instead. useEffect(() => { gsap.registerPlugin(ScrollTrigger); const scroller = container?.current ?? findScroller(trackRef.current); const frames = frameRefs.current; const context = gsap.context(() => { // Each frame waits below its first slot, at nothing. items.forEach((_, index) => { const from = index === 0 ? HERO_AT : NEXT_AT; gsap.set(frames[index], { x: from.x, y: from.y + RISE, scale: 0, opacity: 0, }); }); const timeline = gsap.timeline({ defaults: { ease: "power2.inOut", duration: STEP }, scrollTrigger: { trigger: trackRef.current, scroller: scroller ?? undefined, start: "top top", end: "bottom bottom", scrub: true, onUpdate: (self) => { // The timeline runs one cycle per image, so progress scales // straight onto the image index. const unit = self.progress * items.length; setActive(Math.min(items.length - 1, Math.floor(unit))); }, }, }); const enterCentre = { ...HERO_AT, scale: 1, opacity: 1 }; const enterWaiting = { ...NEXT_AT, scale: SMALL_SCALE, opacity: 1 }; const leaveToCorner = { ...EXIT_AT, scale: SMALL_SCALE }; const leaveEntirely = { scale: 0, opacity: 0 }; /** Skips frames that do not exist at the edges of the relay. */ const move = (frame: HTMLElement | null | undefined, to: object, at: number) => { if (frame) timeline.to(frame, to, at); }; // Opening move: the first image grows straight into the centre while the // second rises into the waiting slot. move(frames[0], enterCentre, 0); move(frames[1], enterWaiting, 0); // Then one promotion per image, each followed by a gap in the timeline — // that gap is the freeze, and it applies to every frame at once. for (let step = 1; step < items.length; step += 1) { const at = step * cycle; move(frames[step - 1], leaveToCorner, at); move(frames[step], enterCentre, at); move(frames[step + 1], enterWaiting, at); move(frames[step - 2], leaveEntirely, at); } // Hold the timeline open through the final freeze. timeline.to({}, { duration: dwell }, items.length * cycle - dwell); }, rootRef); return () => context.revert(); }, [items, container, cycle, dwell]); useIsomorphicLayoutEffect(() => { if (!copyRef.current) return; gsap.fromTo( copyRef.current, { y: 24, opacity: 0 }, { y: 0, opacity: 1, duration: 0.4, ease: "power2.out", overwrite: true }, ); }, [active]); useEffect(() => { const node = stageRef.current; if (!node) return; const observer = new ResizeObserver(([entry]) => { const { width, height } = entry!.contentRect; setScale(Math.min(width / GRID.width, height / GRID.height)); ScrollTrigger.refresh(); }); observer.observe(node); return () => observer.disconnect(); }, []); const item = items[active]; // A transition, its freeze, and one screen for the section that follows. const screens = 1 + items.length * cycle; return ( <div ref={rootRef} className={cn("relative", className)}> <div ref={trackRef} style={{ height: `calc(${screen} * ${screens})` }}> <div ref={stageRef} className="sticky top-0 overflow-hidden bg-[#e3e3e3] text-[#1c1c1c]" style={{ height: screen }} > {/* Absolute, so a 1440px artboard never forces the layout of the container it is being scaled down to fit inside. */} <div className="absolute top-1/2 left-1/2" style={{ width: GRID.width, height: GRID.height, transform: `translate(-50%, -50%) scale(${scale ?? 1})`, visibility: scale === null ? "hidden" : "visible", }} > {items.map((entry, index) => ( <figure key={entry.image} ref={(node) => { frameRefs.current[index] = node; }} style={{ width: HERO.width, height: HERO.height }} className="absolute top-0 left-0 overflow-hidden rounded-[30px] bg-[#c9c9c9] shadow-[0_40px_90px_-50px_rgb(0_0_0/0.55)]" > <img src={entry.image} alt={entry.title} draggable={false} className="size-full object-cover" /> </figure> ))} {/* Copy sits in the gutter to the right of the hero. */} <div className="absolute flex flex-col justify-center" style={{ left: COLUMN.x, top: COPY.y, width: COLUMN.width, height: COPY.height, }} > <div ref={copyRef}> {item?.meta ? ( <p className="text-[15px] tracking-[0.18em] text-[#8a8a8a] uppercase"> {item.meta} </p> ) : null} <h3 className="mt-4 text-[44px] leading-[1.05] font-semibold tracking-tight text-balance"> {item?.title} </h3> <p className="mt-5 text-[17px] leading-relaxed text-[#5c5c5c]"> {item?.description} </p> </div> <p className="mt-10 font-mono text-[13px] tabular-nums text-[#8a8a8a]"> {String(active + 1).padStart(2, "0")} —{" "} {String(items.length).padStart(2, "0")} </p> </div> </div> </div> </div> {children ? <div className="relative z-10">{children}</div> : null} </div> ); }
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| items | RelayItem[] | — | Each entry needs an `image`, `title` and `description`, plus an optional `meta` line. |
| screen | string | "100vh" | Height of one pinned screen. Match the scroll container when it is not the page. |
| container | RefObject<HTMLElement | null> | — | The scrollable ancestor, when the section lives inside one instead of the page. |
| hold | number | 0.4 | Freeze after each promotion, as a share of one transition's length. Every frame is still during it. |
| children | ReactNode | — | Rendered after the pinned track — the section that pushes the images up. |