← каталог
Grid Motion Reveal
Grid-интро: центральная плитка уменьшается из scale(3) до своего места в сетке, слои соседних плиток проявляются каскадом (subgrid, пришпиленная сцена).
gsaplenisscrollrevealstaggeradvanced~30 КБreduced-motion ✓
↕ демо длиннее кадра — прокрутите его
Установка
npx shadcn@latest add https://motiva.pages.dev/r/grid-motion-reveal.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";
/** Градиентные плитки-заглушки вместо фото (замени на <img>). */
const TILE_HUES = [280, 230, 170, 60, 330, 20];
const tileArt = (hue: number) =>
`linear-gradient(150deg, oklch(0.55 0.16 ${hue} / 0.75) 0%, oklch(0.2 0.04 ${hue}) 70%), oklch(0.17 0.02 270)`;
/**
* Grid-motion интро: пришпиленная сцена, центральная плитка уменьшается из scale(3) до места
* в сетке, три слоя соседних плиток проявляются каскадом (subgrid + scrub). Уважает
* reduced-motion / data-shot (финальная сетка статично).
*/
export function GridMotionReveal() {
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>(".gmr-layer");
const scaler = el.querySelector<HTMLElement>(".gmr-scaler-art");
if (frozen) {
layers.forEach((l) => {
l.style.opacity = "1";
l.style.scale = "1";
});
if (scaler) scaler.style.scale = "1";
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 scene = el.querySelector(".gmr-scene");
if (!scene || !scaler) return;
// Стартовый масштаб считаем от реальной высоты сцены: «одна плитка на весь экран»
// должна ВЛЕЗАТЬ, иначе первый кадр показывает обрезок. Фиксированный scale:3
// ломался в невысоком iframe превью.
const stageH = el.querySelector<HTMLElement>(".gmr-sticky")?.clientHeight ?? 0;
const tileH = scaler.getBoundingClientRect().height || 1;
const startScale = Math.max(1.8, Math.min(3.4, (stageH * 0.92) / tileH));
gsap.fromTo(
scaler,
{ scale: startScale },
{
scale: 1,
ease: "power2.inOut",
scrollTrigger: { trigger: scene, start: "top top", end: "70% bottom", scrub: 1 },
},
);
const eases = ["power1.inOut", "power3.inOut", "power4.inOut"];
layers.forEach((layer, i) => {
gsap.fromTo(
layer,
{ opacity: 0, scale: 0 },
{
opacity: 1,
scale: 1,
ease: eases[i] ?? "power2.inOut",
scrollTrigger: { trigger: scene, start: "top top", end: `${(1 - i * 0.05) * 100}% bottom`, scrub: 1 },
},
);
});
}, el);
return () => {
ctx.revert();
gsap.ticker.remove(rafFn);
lenis.destroy();
};
}, []);
return (
<div ref={root} style={{ width: "100%", color: "oklch(0.92 0.01 265)" }}>
<style>{`
.gmr-grid {
--gap: clamp(6px, 2.2vw, 28px);
/* Ширина ограничена И по горизонтали, И по вертикали. 5 колонок, плитки 4:5 →
высота сетки = 3.75 × ширины колонки + 2 зазора. Без верхней границы по высоте
сетка вылезает за sticky-контейнер (у него overflow:hidden) и её обрезает —
особенно в невысоком iframe превью. */
--fit-h: calc((88vh - 2 * var(--gap)) / 3.75 * 5 + 4 * var(--gap));
position: absolute; top: 50%; left: 50%; translate: -50% -50%;
width: min(1000px, 92vw, var(--fit-h));
display: grid; grid-template-columns: repeat(5, 1fr); grid-template-rows: repeat(3, auto);
gap: var(--gap);
}
.gmr-layer {
display: grid; grid-column: 1 / -1; grid-row: 1 / -1;
grid-template-columns: subgrid; grid-template-rows: subgrid;
opacity: 0; scale: 0;
}
.gmr-layer:nth-of-type(1) > div:nth-of-type(odd) { grid-column: 1; }
.gmr-layer:nth-of-type(1) > div:nth-of-type(even) { grid-column: -2; }
.gmr-layer:nth-of-type(2) > div:nth-of-type(odd) { grid-column: 2; }
.gmr-layer:nth-of-type(2) > div:nth-of-type(even) { grid-column: -3; }
.gmr-layer:nth-of-type(3) > div:first-of-type { grid-column: 3; grid-row: 1; }
.gmr-layer:nth-of-type(3) > div:last-of-type { grid-column: 3; grid-row: -1; }
.gmr-tile { aspect-ratio: 4 / 5; border-radius: 12px; border: 1px solid oklch(0.3 0.02 270); }
`}</style>
<section style={{ minHeight: "45vh", display: "flex", alignItems: "flex-end", padding: "0 6vw 6vh" }}>
<h2 style={{ margin: 0, fontSize: "clamp(3rem, 2rem + 6vw, 7rem)", lineHeight: 0.9, letterSpacing: "-0.04em" }}>
let's
<br />
scroll.
</h2>
</section>
<div className="gmr-scene" style={{ minHeight: "220vh" }}>
<div
className="gmr-sticky"
style={{
position: "sticky",
top: 0,
height: "100vh",
display: "flex",
alignItems: "center",
overflow: "hidden",
}}
>
<div className="gmr-grid">
<div className="gmr-layer">
{TILE_HUES.map((h) => (
<div key={`l1-${h}`}>
<div className="gmr-tile" style={{ background: tileArt(h) }} />
</div>
))}
</div>
<div className="gmr-layer">
{TILE_HUES.map((h) => (
<div key={`l2-${h}`}>
<div className="gmr-tile" style={{ background: tileArt((h + 40) % 360) }} />
</div>
))}
</div>
<div className="gmr-layer">
{[110, 250].map((h) => (
<div key={`l3-${h}`}>
<div className="gmr-tile" style={{ background: tileArt(h) }} />
</div>
))}
</div>
<div style={{ position: "relative", gridArea: "2 / 3", zIndex: 2 }}>
<div
className="gmr-scaler-art gmr-tile"
style={{
position: "absolute",
top: "50%",
left: "50%",
translate: "-50% -50%",
width: "100%",
height: "100%",
background:
"radial-gradient(circle at 35% 30%, oklch(0.8 0.12 90 / 0.9), transparent 55%), linear-gradient(150deg, oklch(0.6 0.19 300) 0%, oklch(0.2 0.05 290) 75%)",
}}
/>
</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>
);
}
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import Lenis from "lenis";
/**
* Grid Motion Reveal — vanilla JS/GSAP+Lenis (без React). Пришпиленная сцена: центральная
* плитка уменьшается из scale(3) в свою ячейку, слои соседних плиток проявляются каскадом.
*
* <div data-gmr-scene style="min-height:220vh">
* <div data-gmr-stage style="position:sticky;top:0;height:100vh;overflow:hidden">
* …сетка с [data-gmr-layer] (слои) и [data-gmr-scaler] (центральная)…
* </div>
* </div>
* <script type="module">
* import { initGridMotionReveal } from './grid-motion-reveal.vanilla.js';
* const destroy = initGridMotionReveal(document.querySelector('[data-gmr-scene]'));
* </script>
*
* @param {HTMLElement} scene — сцена с [data-gmr-layer] и [data-gmr-scaler]
* @returns {() => void} destroy
*/
export function initGridMotionReveal(scene) {
const layers = [...scene.querySelectorAll("[data-gmr-layer]")];
const scaler = scene.querySelector("[data-gmr-scaler]");
if (!scaler) return () => {};
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (document.documentElement.hasAttribute("data-shot") || reduce) {
layers.forEach((l) => {
l.style.opacity = "1";
l.style.scale = "1";
});
scaler.style.scale = "1";
return () => {};
}
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);
// Стартовый масштаб — от реальной высоты сцены: «одна плитка на весь экран» должна ВЛЕЗАТЬ,
// иначе первый кадр показывает обрезок (фиксированный scale:3 ломался в невысоком контейнере).
const stageH = (scaler.closest("[data-gmr-stage]") ?? scene).clientHeight || 0;
const tileH = scaler.getBoundingClientRect().height || 1;
const startScale = Math.max(1.8, Math.min(3.4, (stageH * 0.92) / tileH));
const ctx = gsap.context(() => {
gsap.fromTo(
scaler,
{ scale: startScale },
{ scale: 1, ease: "power2.inOut", scrollTrigger: { trigger: scene, start: "top top", end: "70% bottom", scrub: 1 } },
);
const eases = ["power1.inOut", "power3.inOut", "power4.inOut"];
layers.forEach((layer, i) => {
gsap.fromTo(
layer,
{ opacity: 0, scale: 0 },
{
opacity: 1,
scale: 1,
ease: eases[i] ?? "power2.inOut",
scrollTrigger: { trigger: scene, start: "top top", end: `${(1 - i * 0.05) * 100}% bottom`, scrub: 1 },
},
);
});
}, scene);
return () => {
ctx.revert();
gsap.ticker.remove(rafFn);
lenis.destroy();
};
}
Похожие эффекты
лицензия: MITавтор: Motivaисточник: оригинал (порт из fb2025/astro-boilerplate юзера, pm-gridmotion)v1.0.0