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

Morph Transition

Переход общим элементом: карточка из сетки не исчезает, а физически перелетает и разворачивается в обложку детальной страницы (GSAP Flip).

gsapclickmorphadvanced~78 КБreduced-motion ✓
🎛 Настройки — крути и копируй

Пропсы

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

ПропТипПо умолчаниюЧто это
duration?number0.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

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