Skip to content
goodtabs UI
BrowseBrowse components

Zoom handover

A framed image scales up to fill the window, then hands over to the content inside it.

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/zoom-handover.json

With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/zoom-handover

A heading over a framed image. Scroll and the frame fills the window, then the content takes over.

Usage

import { ZoomHandover } from "@/components/ui/zoom-handover"

<ZoomHandover aria-label="Inside the product" src="/product.jpg" alt="" lead={<h2>A frame is a promise.</h2>}>
  <h3>Now the image is the room.</h3>
</ZoomHandover>

Props

PropTypeDefaultDescription
srcstringThe image in the frame, required.
altstringDescribes the image, required. Empty when decorative.
leadReactNodeSits above the frame at rest and leaves first.
framenumber0.66Frame size at rest, as a fraction of the viewport.
pacenumber2.4Pin distance, as a multiple of the viewport height.
childrenReactNodeThe content the frame hands over to.

Source

zoom-handover/zoom-handover.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)";

/** Gap under the frame at rest, as a fraction of the viewport height. */
const GUTTER = 0.05;

type ZoomHandoverProps = Omit<React.ComponentProps<"section">, "title"> & {
  /** The image in the frame. */
  src: string;
  /** Describes the image. Pass an empty string when it is decorative. */
  alt: string;
  /** Sits above the frame at rest and leaves first as the frame grows. */
  lead?: React.ReactNode;
  /** Frame size at rest, as a fraction of the viewport. */
  frame?: number;
  /** Pin distance, as a multiple of the viewport height. */
  pace?: number;
};

/**
 * A framed image that scales up to fill the window, then hands over to the content inside it.
 * The frame is laid out at full size and scaled down, so one number moves it and nothing ever
 * upscales past its layout size. Below md and under reduced motion it is a lead, an image and the
 * content in a plain column, and with no script the content sits in flow under the stage.
 */
function ZoomHandover({
  src,
  alt,
  lead,
  frame = 0.66,
  pace = 2.4,
  className,
  style,
  children,
  ...props
}: ZoomHandoverProps) {
  const root = React.useRef<HTMLElement>(null);
  const rest = ((1 - frame) / 2 - GUTTER) * 100;

  useGSAP(
    () => {
      const media = gsap.matchMedia();
      media.add(STAGE, () => {
        const section = root.current;
        const plate = section?.querySelector<HTMLElement>("[data-slot=zoom-handover-frame]");
        if (!section || !plate) return;
        // Swaps the content from in flow to on top of the stage before anything is measured.
        section.setAttribute("data-staged", "");

        // The viewport corner clears a corner of radius r once the box overhangs it by 0.293r on both axes.
        const cover = () => {
          const radius = parseFloat(getComputedStyle(plate).borderTopLeftRadius) || 0;
          const overhang = radius * (1 - Math.SQRT1_2) + 1;
          return 1 + (2 * overhang) / Math.min(section.clientWidth, section.clientHeight);
        };

        const timeline = gsap.timeline({
          defaults: { ease: "none" },
          scrollTrigger: {
            trigger: section,
            pin: true,
            start: "top top",
            end: () => `+=${pace * window.innerHeight}`,
            scrub: true,
            invalidateOnRefresh: true,
          },
        });
        const copy = section.querySelector("[data-slot=zoom-handover-lead]");
        if (copy) timeline.to(copy, { autoAlpha: 0, y: -24, duration: 0.18 }, 0);
        timeline
          .fromTo(plate, { y: 0, yPercent: rest, scale: frame }, { yPercent: 0, scale: cover, duration: 0.6 }, 0)
          .fromTo("[data-slot=zoom-handover-image]", { scale: 1.14 }, { scale: 1, duration: 0.6 }, 0)
          // A beat at full bleed, then the scrim comes up first so the copy never lands on the raw image.
          .to("[data-slot=zoom-handover-scrim]", { opacity: 0.88, duration: 0.14 }, 0.68)
          .fromTo(
            "[data-slot=zoom-handover-content]",
            { autoAlpha: 0, y: 24 },
            { autoAlpha: 1, y: 0, duration: 0.16 },
            0.76,
          )
          .to({}, { duration: 0.08 });

        return () => section.removeAttribute("data-staged");
      });
      return () => media.revert();
    },
    { scope: root, dependencies: [frame, pace, rest] },
  );

  return (
    <section
      ref={root}
      data-slot="zoom-handover"
      className={cn(
        "group/zoom relative bg-background data-staged:h-dvh data-staged:min-h-96 data-staged:overflow-hidden",
        className,
      )}
      style={{ "--zoom-frame": frame, "--zoom-rest": `${rest}%`, ...style } as React.CSSProperties}
      {...props}
    >
      <div
        data-slot="zoom-handover-stage"
        className="relative md:motion-safe:h-dvh md:motion-safe:min-h-96 md:motion-safe:overflow-hidden"
      >
        {lead ? (
          <div
            data-slot="zoom-handover-lead"
            className="relative z-10 mx-auto w-full max-w-6xl px-6 pt-20 pb-10 md:px-8 md:motion-safe:pt-12 md:motion-safe:pb-0"
          >
            {lead}
          </div>
        ) : null}
        <div
          data-slot="zoom-handover-frame"
          className={cn(
            "relative aspect-3/2 w-full overflow-hidden border-y bg-muted",
            "md:motion-reduce:mx-auto md:motion-reduce:w-[calc(100%-4rem)] md:motion-reduce:max-w-6xl md:motion-reduce:rounded-lg md:motion-reduce:border",
            "md:motion-safe:absolute md:motion-safe:inset-0 md:motion-safe:aspect-auto md:motion-safe:border",
            "md:motion-safe:rounded-[calc(var(--radius)/var(--zoom-frame))]",
            "md:motion-safe:[transform:translateY(var(--zoom-rest))_scale(var(--zoom-frame))]",
          )}
        >
          {/* eslint-disable-next-line @next/next/no-img-element -- a registry item cannot assume next/image */}
          <img
            data-slot="zoom-handover-image"
            src={src}
            alt={alt}
            className="absolute inset-0 size-full object-cover md:motion-safe:scale-[1.14]"
          />
          <div
            data-slot="zoom-handover-scrim"
            aria-hidden="true"
            className="absolute inset-0 bg-background opacity-0"
          />
        </div>
      </div>
      <div
        data-slot="zoom-handover-content"
        className="relative z-10 flex items-center group-data-staged/zoom:absolute group-data-staged/zoom:inset-0"
      >
        <div className="mx-auto w-full max-w-6xl px-6 py-16 group-data-staged/zoom:py-0 md:px-8 md:py-24">
          {children}
        </div>
      </div>
    </section>
  );
}

export { ZoomHandover };

For AI agents

Hand your agent this page, the markdown at /components/zoom-handover/prompt.md, or the Copy AI prompt button above.

When to use

  • A hero that needs to walk people into the product
  • Moving from a single image to the section that explains it
  • Once on a landing page, as the opening move

When not to use

  • For reference content people need to reach fast: a plain image and heading is quicker
  • Next to another pinned section, 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: a lead, a full width image, then the content
  • Give it an aria-label, because it renders a section landmark

The maths

  • The frame is one number: rest scale 0.66 of the window, so 1280 by 800 rests at 845 by 528 and the aspect never changes.
  • Layout radius is 12px / 0.66 = 18.2px, so the frame reads 12px at rest while it is laid out at full size.
  • Corners clear the window once the frame overhangs by 0.293r, so the end scale is 1 + 2(0.293r + 1) / min(w, h), 1.016 at 800px tall.