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