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

Photo Grid Intro

Фото-интро: одна карточка во весь экран уезжает в свою ячейку мозаики 5×3, остальные влетают со своих краёв с расфокуса — каскадом от центра.

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

Пропсы

Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.

ПропТипПо умолчаниюЧто это
photos?string[]PHOTO_IDS.map((id) => photo(id)), titl…URL картинок: ровно 15 штук (5×3). По умолчанию — демо-снимки каталога.
title?stringЗаголовок над сценой.

Установка

npx shadcn@latest add https://motiva.pages.dev/r/photo-grid-intro.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 PHOTO_IDS = [
  1015, 1016, 1018, 1019, 1021, 1022, 1024, 1025, 1029, 1035, 1039, 1043, 1050, 1057, 1069,
];
const photo = (id: number, w = 600, h = 750) => `https://motiva.pages.dev/img/${id}-${w}x${h}.jpg`;

const COLS = 5;
const ROWS = 3;
const CENTER = Math.floor((COLS * ROWS) / 2); // индекс центральной ячейки (2-я строка, 3-я колонка)

/** Откуда прилетает плитка: с ближайшего края экрана + доворот. */
function entry(index: number) {
  const col = index % COLS;
  const row = Math.floor(index / COLS);
  const dx = col - (COLS - 1) / 2;
  const dy = row - (ROWS - 1) / 2;
  return {
    x: dx * 60 + Math.sign(dx) * 220,
    y: dy * 40 + Math.sign(dy) * 160,
    rotate: dx * 3.5,
    // каскад: чем дальше от центра, тем позже приходит
    order: Math.hypot(dx, dy),
  };
}

export interface PhotoGridIntroProps {
  /** URL картинок: ровно 15 штук (5×3). По умолчанию — демо-снимки каталога. */
  photos?: string[];
  /** Заголовок над сценой. */
  title?: string;
}

/**
 * Фото-интро: одна карточка почти во весь экран уезжает в свою ячейку мозаики 5×3,
 * а остальные 14 фото ВЛЕТАЮТ со своих сторон экрана с расфокуса, каскадом от центра.
 * Вариант техники `grid-motion-reveal`, но на реальных фото и с другой хореографией
 * (прилёт с краёв + blur, а не масштабирование из нуля).
 * Уважает `prefers-reduced-motion` / `data-shot` — сразу финальная мозаика.
 */
export function PhotoGridIntro({
  photos = PHOTO_IDS.map((id) => photo(id)),
  title = "portfolio",
}: PhotoGridIntroProps) {
  const root = useRef<HTMLDivElement>(null);

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

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

    const tiles = [...el.querySelectorAll<HTMLElement>(".pgi-tile")];
    const hero = el.querySelector<HTMLElement>(".pgi-hero");

    if (frozen) {
      gsap.set(tiles, { x: 0, y: 0, rotate: 0, opacity: 1, filter: "blur(0px)" });
      if (hero) gsap.set(hero, { scale: 1 });
      return;
    }

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

    const ctx = gsap.context(() => {
      const scene = el.querySelector(".pgi-scene");
      const stage = el.querySelector<HTMLElement>(".pgi-stage");
      if (!scene || !stage || !hero) return;

      // «одна карточка на весь экран» — масштаб считаем от РЕАЛЬНОЙ высоты сцены,
      // иначе в невысоком контейнере первый кадр показывает обрезок
      const heroH = hero.getBoundingClientRect().height || 1;
      const start = Math.max(1.6, Math.min(3.6, (stage.clientHeight * 0.94) / heroH));

      gsap.fromTo(
        hero,
        { scale: start },
        {
          scale: 1,
          ease: "power2.inOut",
          scrollTrigger: { trigger: scene, start: "top top", end: "72% bottom", scrub: 1 },
        },
      );

      const maxOrder = Math.max(...tiles.map((_, i) => entry(i).order));
      tiles.forEach((tile, i) => {
        if (i === CENTER) return;
        const e = entry(i);
        const t = e.order / maxOrder; // 0 у центра → 1 у углов
        gsap.fromTo(
          tile,
          { x: e.x, y: e.y, rotate: e.rotate, opacity: 0, filter: "blur(14px)" },
          {
            x: 0,
            y: 0,
            rotate: 0,
            opacity: 1,
            filter: "blur(0px)",
            ease: "power2.out",
            scrollTrigger: {
              trigger: scene,
              start: "top top",
              // дальние плитки стартуют позже и приезжают последними
              end: `${55 + t * 40}% bottom`,
              scrub: 1,
            },
          },
        );
      });
    }, el);

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

  return (
    <div ref={root} style={{ width: "100%", color: "oklch(0.93 0.01 265)" }}>
      <style>{`
        .pgi-grid {
          --gap: clamp(5px, 1.8vw, 22px);
          /* ширина ограничена и по горизонтали, и по высоте: 3 ряда плиток 4:5 должны
             влезать в сцену, иначе sticky (overflow:hidden) обрежет мозаику */
          --fit-h: calc((90vh - 2 * var(--gap)) / 3.75 * 5 + 4 * var(--gap));
          position: absolute; top: 50%; left: 50%; translate: -50% -50%;
          width: min(1080px, 94vw, var(--fit-h));
          display: grid; grid-template-columns: repeat(5, 1fr); gap: var(--gap);
        }
        .pgi-tile { aspect-ratio: 4 / 5; border-radius: 10px; overflow: hidden;
          background: oklch(0.22 0.02 270); will-change: transform, opacity, filter; }
        .pgi-tile img { width: 100%; height: 100%; object-fit: cover; display: block; }
        .pgi-hero { position: relative; z-index: 3; will-change: transform; }
      `}</style>

      <section
        style={{
          minHeight: "42vh",
          display: "flex",
          alignItems: "flex-end",
          padding: "0 6vw 5vh",
        }}
      >
        <h2
          style={{
            margin: 0,
            fontSize: "clamp(2.6rem, 1.6rem + 6vw, 6.5rem)",
            lineHeight: 0.92,
            letterSpacing: "-0.045em",
            fontWeight: 700,
          }}
        >
          {title}
        </h2>
      </section>

      <div className="pgi-scene" style={{ minHeight: "230vh" }}>
        <div
          className="pgi-stage"
          style={{
            position: "sticky",
            top: 0,
            height: "100vh",
            display: "flex",
            alignItems: "center",
            overflow: "hidden",
          }}
        >
          <div className="pgi-grid">
            {photos.slice(0, COLS * ROWS).map((src, i) => (
              <div key={src} className={`pgi-tile${i === CENTER ? " pgi-hero" : ""}`}>
                <img src={src} alt="" loading={i === CENTER ? "eager" : "lazy"} />
              </div>
            ))}
          </div>
        </div>
      </div>

      <section style={{ height: "30vh", display: "grid", placeItems: "center", opacity: 0.5 }}>
        <span style={{ fontFamily: "ui-monospace, monospace", fontSize: "0.78rem" }}>конец сцены</span>
      </section>
    </div>
  );
}
лицензия: MITавтор: Motivaисточник: оригинал (Motiva); вариант техники grid-motion-reveal на реальных фотоv1.0.0