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

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

Что нужно в проекте до установки
  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";

/** Градиентные плитки-заглушки вместо фото (замени на <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>
  );
}
лицензия: MITавтор: Motivaисточник: оригинал (порт из fb2025/astro-boilerplate юзера, pm-gridmotion)v1.0.0