Skip to content
jjswnth/ui

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.

What's next

The section that pushes the relay off screen

04
Frames
1440
Design grid
0
Timers

Scroll inside the frame

Installation

  1. 1. Install the dependencies.

    terminal
    npm install gsap clsx tailwind-merge
  2. 2. Add the cn helper, 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. 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

PropTypeDefaultDescription
itemsRelayItem[]Each entry needs an `image`, `title` and `description`, plus an optional `meta` line.
screenstring"100vh"Height of one pinned screen. Match the scroll container when it is not the page.
containerRefObject<HTMLElement | null>The scrollable ancestor, when the section lives inside one instead of the page.
holdnumber0.4Freeze after each promotion, as a share of one transition's length. Every frame is still during it.
childrenReactNodeRendered after the pinned track — the section that pushes the images up.