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