← каталог
Morph Transition
Переход общим элементом: карточка из сетки не исчезает, а физически перелетает и разворачивается в обложку детальной страницы (GSAP Flip).
gsapclickmorphadvanced~78 КБreduced-motion ✓
🎛 Настройки — крути и копируй
Пропсы
Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.
| Проп | Тип | По умолчанию | Что это |
|---|---|---|---|
| duration? | number | 0.62 | Длительность полёта, сек (формат «0.62»). |
| ease? | string | "power3.inOut" | Кривая GSAP (формат «power3.inOut»). |
Установка
npx shadcn@latest add https://motiva.pages.dev/r/morph-transition.jsonзависимости: gsap@^3.15.0
Что нужно в проекте до установки
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 { useLayoutEffect, useRef, useState } from "react";
import gsap from "gsap";
import { Flip } from "gsap/Flip";
/** Демо-снимки каталога — замени на свои. */
const ITEMS = [
{ id: 1015, title: "Fjord", tag: "Пейзаж" },
{ id: 1024, title: "Aerial", tag: "Съёмка" },
{ id: 1039, title: "Falls", tag: "Природа" },
];
const photo = (id: number, w = 900, h = 700) => `https://motiva.pages.dev/img/${id}-${w}x${h}.jpg`;
export interface MorphTransitionProps {
/** Длительность полёта, сек (формат «0.62»). */
duration?: number;
/** Кривая GSAP (формат «power3.inOut»). */
ease?: string;
}
/**
* Переход общим элементом (shared element): карточка из сетки не исчезает и не появляется
* заново — она визуально ПЕРЕЛЕТАЕТ и разворачивается в обложку детальной страницы.
*
* Устройство важнее кода: СЕТКА НИКОГДА НЕ ПЕРЕСТРАИВАЕТСЯ. Первая версия делала открытую
* карточку absolute — та выпадала из потока, соседи пересаживались в чужие колонки, и на
* обратном полёте Flip вёз их с чужих мест в родные: соседи «прыгали вбок» на каждом
* закрытии. Теперь карточка-источник остаётся в своей клетке (прячется visibility, клетка
* занята), а летает ОТДЕЛЬНЫЙ hero-слой с тем же data-flip-id: Flip сшивает старые границы
* с новым элементом сам. Соседям остаётся чистый CSS-фейд НА МЕСТЕ — их GSAP не трогает
* вовсе, поэтому переход по opacity честно стартует.
*
* Клик поверх идущего полёта не глотается защёлкой (это читалось как «работает через
* раз»): предыдущий полёт мгновенно долетает (progress(1)), и новый стартует из честной
* конечной раскладки. Reduced-motion/data-shot — смена состояния без анимации.
*/
export function MorphTransition({
duration = 0.62,
ease = "power3.inOut",
}: MorphTransitionProps = {}) {
const [open, setOpen] = useState<number | null>(null);
const root = useRef<HTMLDivElement>(null);
const snapshot = useRef<Flip.FlipState | null>(null);
const flight = useRef<ReturnType<typeof Flip.from> | null>(null);
/** id карточки, которая закрывается: в момент эффекта open уже null. */
const closingId = useRef<string | null>(null);
const frozen = () =>
document.documentElement.hasAttribute("data-shot") ||
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const go = (next: number | null) => {
if (next === open) return;
if (frozen()) {
setOpen(next);
return;
}
gsap.registerPlugin(Flip);
// не глотать клик поверх полёта: долетаем мгновенно и стартуем новый из финала
if (flight.current?.isActive()) flight.current.progress(1);
// снимок того, что сейчас ВИДИМО под этим id: при открытии — карточка сетки,
// при закрытии — hero-слой
const flying =
next !== null
? root.current!.querySelector(`.mrt-grid [data-flip-id="card-${ITEMS[next]!.id}"]`)
: root.current!.querySelector(".mrt-hero");
closingId.current = next === null && open !== null ? `card-${ITEMS[open]!.id}` : null;
snapshot.current = flying ? Flip.getState(flying, { props: "borderRadius" }) : null;
setOpen(next);
};
useLayoutEffect(() => {
if (!snapshot.current) return;
const st = snapshot.current;
snapshot.current = null;
// цель полёта — элемент-ПРИЁМНИК с тем же data-flip-id, что в снимке
const target =
open !== null
? root.current!.querySelector<HTMLElement>(".mrt-hero")
: root.current!.querySelector<HTMLElement>(`.mrt-grid [data-flip-id="${closingId.current}"]`);
if (!target) return;
flight.current = Flip.from(st, {
targets: target,
duration,
ease,
// БЕЗ absolute: true — он вынимал принимающую карточку из потока, и на обратном
// полёте сетка перестраивалась (сосед прыгал в чужую колонку, замер: x 722 → 370).
// Полёт трансформами + inline width/height не трогает 1fr-треки (страховка —
// minmax(0, 1fr) в шаблоне), а поверх соседей летящего поднимает zIndex.
zIndex: 7,
props: "borderRadius",
onComplete: () => {
flight.current = null;
},
});
// Кегль подписи едет тем же движком и с теми же таймингами, что полёт: CSS-переход
// здесь не жилец — Flip переписывает inline-стили в том же кадре.
const meta = target.querySelector(".mrt-meta");
if (meta) {
gsap.fromTo(
meta,
{ fontSize: open !== null ? "1rem" : "2rem" },
{ fontSize: open !== null ? "2rem" : "1rem", duration, ease, clearProps: "fontSize" },
);
}
return () => {
flight.current?.kill();
flight.current = null;
};
}, [open, duration, ease]);
return (
<div
ref={root}
style={{
position: "absolute",
inset: 0,
overflow: "hidden",
background: "oklch(0.13 0.02 270)",
color: "oklch(0.94 0.01 265)",
}}
>
<style>{`
.mrt-grid { position: absolute; inset: 0; display: grid; gap: 14px; padding: 18px;
/* minmax(0, 1fr): содержимое не должно раздувать трек */
grid-template-columns: repeat(3, minmax(0, 1fr)); align-content: center; }
/* Клетка-обёртка ДЕРЖИТ МЕСТО в сетке всегда: размер задаёт она, а не карточка.
Летящая карточка absolute внутри клетки — её inline width/height на время
полёта в раскладке НЕ УЧАСТВУЮТ вовсе: ни треки, ни строки не шевелятся. */
.mrt-cell { position: relative; aspect-ratio: 3 / 4; }
.mrt-card { position: absolute; inset: 0; border-radius: 14px; overflow: hidden;
cursor: pointer; padding: 0; border: 1px solid oklch(0.3 0.02 270);
background: oklch(0.18 0.02 270);
/* фейд соседей — чистый CSS на месте: их клетки не меняются, GSAP их не трогает */
transition: opacity 0.34s ease; }
.mrt-card img, .mrt-hero img { display: block; width: 100%; height: 100%; object-fit: cover; }
/* источник остаётся В КЛЕТКЕ: сетка не перестраивается, соседи не прыгают */
.mrt-card.is-src { visibility: hidden; }
.mrt-card.is-dim { opacity: 0; pointer-events: none; }
.mrt-hero { position: absolute; inset: 0; z-index: 5; border: 0; padding: 0;
border-radius: 0; overflow: hidden; cursor: pointer; background: oklch(0.18 0.02 270); }
.mrt-meta { position: absolute; left: 14px; bottom: 12px; font-size: 1rem; text-align: left;
color: oklch(0.94 0.01 265); text-shadow: 0 2px 10px oklch(0 0 0 / 0.8); }
.mrt-hero .mrt-meta { font-size: 2rem; }
.mrt-title { font-size: 1em; font-weight: 700; letter-spacing: -0.01em; display: block; }
.mrt-tag { font-family: ui-monospace, monospace; font-size: 0.7em; opacity: 0.7; }
@media (prefers-reduced-motion: reduce) { .mrt-card { transition: none; } }
`}</style>
<div className="mrt-grid">
{ITEMS.map((item, i) => (
<div className="mrt-cell" key={item.id}>
<button
data-flip-id={`card-${item.id}`}
className={`mrt-card${open === i ? " is-src" : ""}${open !== null && open !== i ? " is-dim" : ""}`}
onClick={() => go(i)}
aria-expanded={open === i}
>
<img src={photo(item.id)} alt={item.title} loading="lazy" />
<span className="mrt-meta">
<span className="mrt-title">{item.title}</span>
<span className="mrt-tag">{item.tag}</span>
</span>
</button>
</div>
))}
</div>
{open !== null && (
<button
className="mrt-hero"
data-flip-id={`card-${ITEMS[open]!.id}`}
onClick={() => go(null)}
aria-label="Назад в сетку"
>
<img src={photo(ITEMS[open]!.id)} alt={ITEMS[open]!.title} />
<span className="mrt-meta">
<span className="mrt-title">{ITEMS[open]!.title}</span>
<span className="mrt-tag">клик — назад в сетку</span>
</span>
</button>
)}
{open === null && (
<div
style={{
position: "absolute",
top: 14,
left: 18,
fontFamily: "ui-monospace, monospace",
fontSize: "0.72rem",
opacity: 0.6,
zIndex: 6,
}}
>
жми карточку — она перелетит в обложку
</div>
)}
</div>
);
}
Похожие эффекты
лицензия: MITавтор: Motivaисточник: оригинал (Motiva)v1.0.0