Motiva
Мои коллекции
← каталог

Zoom Through

Скролл «везёт камеру» сквозь слои: каждый масштабируется на зрителя и растворяется, финал — посадка на контент.

gsaplenisscrollparallaxmorphadvanced~30 КБreduced-motion ✓
↕ демо длиннее кадра — прокрутите его

Установка

npx shadcn@latest add https://motiva.pages.dev/r/zoom-through.json

зависимости: gsap@^3.15.0 · lenis@^1.3.25

Что нужно в проекте до установки
  1. components.json в корне — с алиасами в форме"components": "@/components". Нет файла — CLI не поймёт, куда класть.
  2. Алиас @ объявлен дважды: "paths": {"@/*": ["./src/*"]} в корневом tsconfig.json (в Vite он часто содержит только references — тогда CLI создаёт папку@) и resolve.alias в конфиге сборщика.
  3. Токены подключены один раз на приложение:import "@/styles/motiva-tokens.css" — они приезжают вместе с записью. Без них секция потеряет цвета, ритм и тему.
  4. Своя типографика не должна спорить: если у вас на обёрткеtext-align: center, он протечёт внутрь блоков.
  5. Проект без TypeScript? Всё равно нужен минимальныйtsconfig.json с pathsи "tsx": true в components.json: CLI грузит tsconfig безусловно, а с "tsx": false идёт за jsconfig.json и падает так же. Приехавший рядом.tsx можно не трогать — вам нужен.vanilla.js.

Экспорт

↓ Скачать .zip

Код

"use client";

import { useEffect, useRef } from "react";
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import Lenis from "lenis";

const LAYERS = [
  { word: "Дальше", hue: 280 },
  { word: "Глубже", hue: 230 },
  { word: "Сквозь", hue: 170 },
];

/**
 * Zoom-through: скролл «везёт камеру» сквозь слои — каждый масштабируется на зрителя и
 * растворяется, открывая следующий; финал — посадка на контент. Пин через sticky.
 * Уважает reduced-motion / data-shot (сразу финальный экран).
 */
export function ZoomThrough() {
  const root = useRef<HTMLDivElement>(null);

  useEffect(() => {
    const el = root.current;
    if (!el) return;

    const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    const frozen = document.documentElement.hasAttribute("data-shot") || reduce;

    const layers = el.querySelectorAll<HTMLElement>(".zt-layer");
    const final = el.querySelector<HTMLElement>(".zt-final");

    if (frozen) {
      layers.forEach((l) => (l.style.opacity = "0"));
      if (final) {
        final.style.opacity = "1";
        final.style.transform = "none";
      }
      return;
    }

    gsap.registerPlugin(ScrollTrigger);
    const lenis = new Lenis({ duration: 1.1 });
    lenis.on("scroll", ScrollTrigger.update);
    const rafFn = (time: number) => lenis.raf(time * 1000);
    gsap.ticker.add(rafFn);
    gsap.ticker.lagSmoothing(0);

    const ctx = gsap.context(() => {
      const wrap = el.querySelector(".zt-wrap");
      if (!wrap || !final) return;

      const tl = gsap.timeline({
        scrollTrigger: { trigger: wrap, start: "top top", end: "bottom bottom", scrub: true },
      });
      // кроссфейд-цепочка: пока слой i летит в камеру и тает, следующий приближается из глубины —
      // одновременно виден максимум один «полный» слой (без наложения надписей)
      layers.forEach((layer, i) => {
        tl.to(layer, { scale: 2.8, opacity: 0, ease: "power1.in", duration: 1 }, i);
        const next = layers[i + 1];
        if (next) {
          tl.fromTo(
            next,
            { scale: 0.5, opacity: 0 },
            { scale: 1, opacity: 1, ease: "power1.out", duration: 1 },
            i,
          );
        }
      });
      tl.fromTo(
        final,
        { scale: 0.82, opacity: 0 },
        { scale: 1, opacity: 1, ease: "power2.out", duration: 0.8 },
        LAYERS.length - 0.55,
      );
    }, el);

    return () => {
      ctx.revert();
      gsap.ticker.remove(rafFn);
      lenis.destroy();
    };
  }, []);

  return (
    <div style={{ width: "100vw", overflowX: "clip", color: "oklch(0.92 0.01 265)" }}>
      <div ref={root}>
        <div className="zt-wrap" style={{ height: "320vh" }}>
          <div
            style={{
              position: "sticky",
              top: 0,
              height: "100vh",
              overflow: "hidden",
              display: "grid",
              placeItems: "center",
            }}
          >
            {LAYERS.map((l, i) => (
              <div
                key={l.word}
                className="zt-layer"
                style={{
                  position: "absolute",
                  inset: 0,
                  display: "grid",
                  placeItems: "center",
                  zIndex: LAYERS.length - i,
                  opacity: i === 0 ? 1 : 0,
                  willChange: "transform, opacity",
                }}
              >
                <div
                  style={{
                    width: "min(56vh, 78vw)",
                    aspectRatio: "1",
                    borderRadius: "50%",
                    border: `2px solid oklch(0.6 0.15 ${l.hue} / 0.8)`,
                    boxShadow: `0 0 90px oklch(0.55 0.16 ${l.hue} / 0.35) inset, 0 0 60px oklch(0.55 0.16 ${l.hue} / 0.2)`,
                    display: "grid",
                    placeItems: "center",
                  }}
                >
                  <span style={{ fontSize: "1.6rem", fontWeight: 700, letterSpacing: "-0.02em" }}>{l.word}</span>
                </div>
              </div>
            ))}
            <div className="zt-final" style={{ textAlign: "center", opacity: 0, zIndex: 0 }}>
              <div style={{ fontSize: "2.4rem", fontWeight: 750, letterSpacing: "-0.03em" }}>Прилетели.</div>
              <div style={{ marginTop: 8, fontFamily: "ui-monospace, monospace", fontSize: "0.78rem", opacity: 0.6 }}>
                zoom-through: слои прошли сквозь камеру
              </div>
            </div>
          </div>
        </div>
        <section style={{ height: "35vh", display: "grid", placeItems: "center", opacity: 0.55 }}>
          <span style={{ fontFamily: "ui-monospace, monospace", fontSize: "0.8rem" }}>дальше обычный поток</span>
        </section>
      </div>
    </div>
  );
}
лицензия: MITавтор: Motivaисточник: оригинал (Motiva); идея — Apple Vision Pro / CREATIVE-IDEAS №2v1.0.0