← каталог
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
Что нужно в проекте до установки
components.jsonв корне — с алиасами в форме"components": "@/components". Нет файла — CLI не поймёт, куда класть.- Алиас
@объявлен дважды:"paths": {"@/*": ["./src/*"]}в корневомtsconfig.json(в Vite он часто содержит толькоreferences— тогда CLI создаёт папку@) иresolve.aliasв конфиге сборщика. - Токены подключены один раз на приложение:
import "@/styles/motiva-tokens.css"— они приезжают вместе с записью. Без них секция потеряет цвета, ритм и тему. - Своя типографика не должна спорить: если у вас на обёртке
text-align: center, он протечёт внутрь блоков. - Проект без TypeScript? Всё равно нужен минимальный
tsconfig.jsonсpathsи"tsx": trueвcomponents.json: CLI грузит tsconfig безусловно, а с"tsx": falseидёт заjsconfig.jsonи падает так же. Приехавший рядом.tsxможно не трогать — вам нужен.vanilla.js.
Экспорт
Код
"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>
);
}
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import Lenis from "lenis";
/**
* Zoom Through — vanilla JS/GSAP+Lenis (без React). Скролл «везёт камеру» сквозь слои:
* кроссфейд-цепочка (виден один слой), финал — посадка на контент.
*
* <div data-zt-wrap style="height:320vh">
* <div style="position:sticky;top:0;height:100vh">
* <div data-zt-layer>Слой 1</div><div data-zt-layer>Слой 2</div>
* <div data-zt-final>Финал</div>
* </div>
* </div>
* <script type="module">
* import { initZoomThrough } from './zoom-through.vanilla.js';
* const destroy = initZoomThrough(document.querySelector('[data-zt-wrap]'));
* </script>
*
* @param {HTMLElement} wrap — обёртка с [data-zt-layer] (по порядку) и [data-zt-final]
* @returns {() => void} destroy
*/
export function initZoomThrough(wrap) {
const layers = [...wrap.querySelectorAll("[data-zt-layer]")];
const final = wrap.querySelector("[data-zt-final]");
if (!layers.length || !final) return () => {};
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (document.documentElement.hasAttribute("data-shot") || reduce) {
layers.forEach((l) => (l.style.opacity = "0"));
final.style.opacity = "1";
return () => {};
}
layers.forEach((l, i) => (l.style.opacity = i === 0 ? "1" : "0"));
final.style.opacity = "0";
gsap.registerPlugin(ScrollTrigger);
const lenis = new Lenis({ duration: 1.1 });
lenis.on("scroll", ScrollTrigger.update);
const rafFn = (t) => lenis.raf(t * 1000);
gsap.ticker.add(rafFn);
gsap.ticker.lagSmoothing(0);
const ctx = gsap.context(() => {
const tl = gsap.timeline({
scrollTrigger: { trigger: wrap, start: "top top", end: "bottom bottom", scrub: true },
});
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);
}, wrap);
return () => {
ctx.revert();
gsap.ticker.remove(rafFn);
lenis.destroy();
};
}
Похожие эффекты
лицензия: MITавтор: Motivaисточник: оригинал (Motiva); идея — Apple Vision Pro / CREATIVE-IDEAS №2v1.0.0