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

View Transition Morph

Морф общего элемента без библиотек: одно свойство view-transition-name — и браузер сам интерполирует миниатюру в обложку.

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

Пропсы

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

ПропТипПо умолчаниюЧто это
open?numberДлительность раскрытия, сек (формат «0.56»).
back?numberДлительность возврата, сек (формат «0.46»): короче раскрытия — читается как «назад».

Установка

npx shadcn@latest add https://motiva.pages.dev/r/view-transition-morph.json
Что нужно в проекте до установки
  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 { useRef, useState } from "react";
import { flushSync } from "react-dom";

/** Демо-снимки каталога — замени на свои. */
const ITEMS = [
  { id: 1016, title: "Canyon", tag: "Пейзаж" },
  { id: 1043, title: "Valley", tag: "Съёмка" },
  { id: 1057, title: "Coast", tag: "Природа" },
  { id: 1067, title: "Rooftop", tag: "Город" },
  { id: 1074, title: "Shore", tag: "Берег" },
  { id: 1080, title: "Berries", tag: "Макро" },
];

/**
 * ОДИН адрес на миниатюру и на обложку — и это не мелочь.
 * Если у них разные URL, браузер снимает кадр «после» раньше, чем декодирована большая
 * картинка: в морфе оказывается пустой прямоугольник, и переход выглядит как «всё
 * исчезло, потом прыгнуло». Нужны разные размеры — грей крупный файл заранее
 * (`new Image().decode()` на pointerenter), но никогда не в обработчике клика.
 */
const photo = (id: number) => `https://motiva.pages.dev/img/${id}-900x675.jpg`;

export interface ViewTransitionMorphProps {
  /** Длительность раскрытия, сек (формат «0.56»). */
  open?: number;
  /** Длительность возврата, сек (формат «0.46»): короче раскрытия — читается как «назад». */
  back?: number;
}

/** Плитки, которые не участвуют в морфе, гасим отдельными группами — со сдвигом по времени. */
const TILE_RULES = ITEMS.map(
  (_, i) => `
    .vtm-c${i} { view-transition-name: vtm-c${i}; }
    ::view-transition-old(vtm-c${i}) {
      animation: vtm-tile-out 0.26s ${(i * 0.025).toFixed(3)}s cubic-bezier(0.4, 0, 1, 1) both; }
    ::view-transition-new(vtm-c${i}) {
      animation: vtm-tile-in 0.34s ${(0.06 + i * 0.028).toFixed(3)}s var(--ds-ease-out, cubic-bezier(0.16, 1, 0.3, 1)) both; }`,
).join("");

/**
 * Морф общего элемента БЕЗ библиотек анимации: миниатюра и обложка получают одинаковый
 * `view-transition-name`, и браузер сам интерполирует их геометрию. JS здесь — ровно один
 * вызов `startViewTransition`; всё остальное описано в CSS.
 *
 * Четыре вещи отделяют «нативный морф» от «дёргается и прыгает»:
 *
 * 1. Имя висит на КАРТИНКЕ, а не на карточке целиком. Иначе в снимок попадает подпись
 *    и растягивается вместе с ним — 0.85rem → 1.9rem превращаются в мыло.
 * 2. У миниатюры и обложки один источник (см. `photo`) — иначе кадр «после» пустой.
 * 3. Снимки разных пропорций надо приводить руками: `height: 100%` + `object-fit: cover`.
 *    По умолчанию у псевдоэлементов `height: auto`, и кадр «до» расплющивает по рамке.
 * 4. Соседние плитки не должны исчезать мгновенно. Каждая — своя группа перехода,
 *    поэтому они гаснут волной, а не пропадают на первом кадре.
 *
 * Имя обязано быть УНИКАЛЬНО в момент перехода — поэтому оно проставляется синхронно
 * (`flushSync`) только активной паре и никогда не висит на всех сразу.
 */
export function ViewTransitionMorph({ open: OPEN = 0.56, back: BACK = 0.46 }: ViewTransitionMorphProps = {}) {
  const [open, setOpen] = useState<number | null>(null);
  /** id фото, которое участвует в морфе: имя должно стоять ДО снимка «до». */
  const [shared, setShared] = useState<number | null>(null);
  const busy = useRef(false);

  const go = (next: number | null) => {
    // без защиты клик поверх идущего перехода прерывает предыдущий на середине —
    // браузер откатывает пседо-дерево, и карточка прыгает
    if (busy.current) return;
    const doc = document as Document & {
      startViewTransition?: (cb: () => void) => { finished: Promise<void> };
    };
    const id = next !== null ? ITEMS[next]!.id : shared;
    const frozen =
      document.documentElement.hasAttribute("data-shot") ||
      window.matchMedia("(prefers-reduced-motion: reduce)").matches;

    if (!doc.startViewTransition || frozen) {
      setShared(id);
      setOpen(next);
      return;
    }

    busy.current = true;
    const html = document.documentElement;
    // возврат ведём короче открытия: то же движение в обратную сторону, но собраннее —
    // асимметрия и читается как «назад», а не как повтор
    if (next === null) html.classList.add("vtm-back");
    flushSync(() => setShared(id));
    const vt = doc.startViewTransition(() => flushSync(() => setOpen(next)));
    const done = () => {
      busy.current = false;
      html.classList.remove("vtm-back");
    };
    vt.finished.then(done, done);
  };

  const active = open !== null ? ITEMS[open] : null;

  return (
    <div className="vtm">
      <style>{`
        .vtm { position: absolute; inset: 0; overflow: hidden;
          background: var(--ds-bg, oklch(0.13 0.02 270));
          color: var(--ds-fg, oklch(0.95 0.01 265));
          font-family: var(--ds-font, system-ui, sans-serif); }

        .vtm-grid { position: absolute; inset: 0; padding: 38px 16px 16px; display: grid;
          grid-template-columns: repeat(3, 1fr); grid-template-rows: repeat(2, 1fr); gap: 12px; }
        @media (max-width: 560px) {
          .vtm-grid { grid-template-columns: repeat(2, 1fr); grid-template-rows: repeat(3, 1fr); }
        }

        .vtm-card { position: relative; padding: 0; border: 0; background: none; cursor: pointer;
          border-radius: 12px;
          transition: transform 0.3s var(--ds-ease-out, cubic-bezier(0.16, 1, 0.3, 1)); }
        .vtm-card:hover { transform: translateY(-2px) scale(1.015); }
        .vtm-card:focus-visible { outline: 2px solid var(--ds-accent, oklch(0.72 0.16 300));
          outline-offset: 3px; }
        /* радиус живёт на КАРТИНКЕ, а не на обрезающем родителе: снимок перехода делается
           без учёта клипа предков, и скругление иначе отваливается на первом же кадре */
        .vtm-card img { display: block; width: 100%; height: 100%; object-fit: cover;
          border-radius: 12px; }

        .vtm-hero { position: absolute; inset: 0; padding: 0; border: 0; background: none;
          cursor: pointer; }
        .vtm-hero img { display: block; width: 100%; height: 100%; object-fit: cover; }

        .vtm-meta, .vtm-cap { position: absolute; left: 0; right: 0; bottom: 0; text-align: left;
          color: oklch(0.98 0 0);
          background: linear-gradient(to top, oklch(0 0 0 / 0.74), oklch(0 0 0 / 0)); }
        .vtm-meta { padding: 26px 12px 10px; border-radius: 0 0 12px 12px; }
        .vtm-meta b { display: block; font-size: 0.85rem; font-weight: 700; letter-spacing: -0.01em; }
        .vtm-meta i { font-style: normal; font-size: 0.65rem; opacity: 0.75;
          font-family: var(--ds-font-mono, ui-monospace, monospace); }
        .vtm-cap { padding: 46px 22px 18px; }
        .vtm-cap b { display: block; font-size: 1.9rem; font-weight: 750; letter-spacing: -0.03em; }
        .vtm-cap i { font-style: normal; font-size: 0.72rem; opacity: 0.78;
          font-family: var(--ds-font-mono, ui-monospace, monospace); }

        .vtm-hint { position: absolute; top: 12px; left: 18px; font-size: 0.72rem; opacity: 0.55;
          font-family: var(--ds-font-mono, ui-monospace, monospace); }

        /* ——— сам переход: ниже нет ни строчки JS ——— */
        .vtm-photo { view-transition-name: vtm-photo; }
        .vtm-cap { view-transition-name: vtm-cap; }

        /* порядок слоёв задаём явно: группы перехода — обычные позиционированные элементы,
           и без z-index обложка накрывает собственную подпись */
        ::view-transition-group(vtm-photo) { z-index: 2; }
        ::view-transition-group(vtm-cap) { z-index: 3; }
        /* кривая морфа мягче общего --ds-ease-out: у «экспоненциального» выезда 80% пути
           проходит за первую четверть времени, и большая геометрия читается как рывок.
           Здесь — «emphasized» из Material: решительный старт, но движение видно целиком */
        ::view-transition-group(vtm-photo) {
          animation-duration: ${OPEN}s; animation-timing-function: cubic-bezier(0.2, 0, 0, 1); }
        /* по умолчанию у снимков height: auto — кадр «до» (4:3) расплющивает по рамке обложки */
        ::view-transition-old(vtm-photo), ::view-transition-new(vtm-photo) {
          width: 100%; height: 100%; object-fit: cover; }
        /* КОГДА менять снимок — половина ощущения «дорого».
           Снимок миниатюры растрирован по размеру плитки: пока рамка маленькая, он резкий,
           а растянутый на всю обложку — мыло. Снимок обложки — наоборот. Поэтому кроссфейд
           всегда делаем в тот момент, когда рамка ближе к ПЛИТКЕ: при открытии — сразу,
           при возврате — в самом конце. Так на экране всегда тот кадр, что сейчас резче. */
        ::view-transition-old(vtm-photo), ::view-transition-new(vtm-photo) {
          animation-duration: 0.22s; animation-timing-function: linear; }
        /* обратный ход — та же геометрия, только собраннее */
        :root.vtm-back::view-transition-group(vtm-photo) { animation-duration: ${BACK}s; }
        :root.vtm-back::view-transition-old(vtm-photo),
        :root.vtm-back::view-transition-new(vtm-photo) {
          animation-duration: 0.2s; animation-delay: 0.26s; }

        ::view-transition-new(vtm-cap) {
          animation: vtm-cap-in 0.42s 0.18s var(--ds-ease-out, cubic-bezier(0.16, 1, 0.3, 1)) both; }
        ::view-transition-old(vtm-cap) { animation: vtm-cap-out 0.16s linear both; }
        ::view-transition-old(root), ::view-transition-new(root) { animation-duration: 0.34s; }
        ${TILE_RULES}

        @keyframes vtm-cap-in { from { opacity: 0; transform: translateY(14px); } }
        @keyframes vtm-cap-out { to { opacity: 0; } }
        @keyframes vtm-tile-out { to { opacity: 0; transform: scale(0.94); } }
        @keyframes vtm-tile-in { from { opacity: 0; transform: scale(0.94); } }

        @media (prefers-reduced-motion: reduce) { .vtm-card { transition: none; } }
      `}</style>

      {active ? (
        <button className="vtm-hero" onClick={() => go(null)} aria-label="Назад в сетку">
          <img className="vtm-photo" src={photo(active.id)} alt={active.title} />
          <span className="vtm-cap">
            <b>{active.title}</b>
            <i>{active.tag} — клик, чтобы вернуться</i>
          </span>
        </button>
      ) : (
        <div className="vtm-grid">
          {ITEMS.map((item, i) => (
            <button
              key={item.id}
              // у активной плитки своего имени НЕТ: её роль в морфе играет вложенная
              // картинка, а вложенные имена внутри именованной группы ведут себя иначе
              // в разных версиях движков — не полагаемся на это
              className={item.id === shared ? "vtm-card" : `vtm-card vtm-c${i}`}
              onClick={() => go(i)}
              aria-label={`Открыть ${item.title}`}
            >
              <img
                className={item.id === shared ? "vtm-photo" : undefined}
                src={photo(item.id)}
                alt={item.title}
                loading="lazy"
              />
              <span className="vtm-meta">
                <b>{item.title}</b>
                <i>{item.tag}</i>
              </span>
            </button>
          ))}
        </div>
      )}

      {open === null && <div className="vtm-hint">view-transition-name — морф без библиотек</div>}
    </div>
  );
}
лицензия: MITавтор: Motivaисточник: оригинал (Motiva)v1.0.0