← каталог
View Transition (CSS)
Переход страниц на View Transitions API: анимация описана только в CSS (::view-transition-old/new), JS — один вызов. Для MPA хватает @view-transition.
cssclickrevealintermediate~1 КБreduced-motion ✓
🎛 Настройки — крути и копируй
Пропсы
Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.
| Проп | Тип | По умолчанию | Что это |
|---|---|---|---|
| duration? | number | 0.4 | Длительность перехода, сек (формат «0.4»). |
| shift? | number | 16 | Смещение приходящего кадра, px (формат «16»): 0 — только растворение, без движения. |
Установка
npx shadcn@latest add https://motiva.pages.dev/r/view-transition-page.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 { useState } from "react";
import { flushSync } from "react-dom";
const PAGES = [
{ title: "Главная", accent: "oklch(0.62 0.19 280)", note: "страница A" },
{ title: "Проекты", accent: "oklch(0.66 0.16 230)", note: "страница B" },
{ title: "Контакты", accent: "oklch(0.7 0.16 60)", note: "страница C" },
];
export interface ViewTransitionPageProps {
/** Длительность перехода, сек (формат «0.4»). */
duration?: number;
/** Смещение приходящего кадра, px (формат «16»): 0 — только растворение, без движения. */
shift?: number;
}
/**
* Переход страниц на View Transitions API: САМА анимация описана только в CSS
* (`::view-transition-old/new`), JS нужен лишь чтобы сказать браузеру «вот момент смены».
* Браузер снимает скриншот до и после, кладёт их в псевдо-дерево и анимирует за нас —
* никаких дублей DOM, оверлеев и таймлайнов.
*
* Многостраничный сайт (без единой строчки JS) — то же самое одной директивой:
*
* @view-transition { navigation: auto; }
*
* Не поддерживается — просто мгновенная смена, ветку писать не нужно: `startViewTransition`
* проверяется один раз, всё остальное деградирует само.
*/
export function ViewTransitionPage({ duration = 0.4, shift = 16 }: ViewTransitionPageProps = {}) {
const [page, setPage] = useState(0);
const go = (next: number) => {
if (next === page) return;
const doc = document as Document & {
startViewTransition?: (cb: () => void) => { finished: Promise<void> };
};
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!doc.startViewTransition || reduce || document.documentElement.hasAttribute("data-shot")) {
setPage(next);
return;
}
// flushSync обязателен: браузеру нужен СИНХРОННО обновлённый DOM внутри колбэка,
// а React по умолчанию откладывает рендер на следующий тик
doc.startViewTransition(() => flushSync(() => setPage(next)));
};
const cur = PAGES[page] ?? PAGES[0]!;
return (
<div style={{ position: "absolute", inset: 0, overflow: "hidden", color: "oklch(0.92 0.01 265)" }}>
<style>{`
/* вся анимация перехода — здесь; JS её не трогает.
Ровно два свойства: transform и opacity. Это принципиально — снимок перехода
это полноэкранный слой, и любой filter/blur на нём заставляет браузер
перерисовывать его каждый кадр, отчего переход и выглядит дёрганым.
На transform+opacity он целиком уезжает в композитор и идёт в 120 fps. */
@keyframes vtp-fade-out { to { opacity: 0; } }
@keyframes vtp-fade-in { from { opacity: 0; } }
@keyframes vtp-lift-out { to { transform: translateY(${-Math.round(shift * 0.62)}px) scale(0.99); } }
@keyframes vtp-lift-in { from { transform: translateY(${shift}px) scale(0.995); } }
/* ДВА свойства — две анимации, у каждой своя кривая. Прозрачность обязана идти
строго линейно и встречно (1→0 и 0→1 за одно и то же время): снимки лежат в
изолированной паре и смешиваются в режиме plus-lighter, где корректный
кроссфейд получается ровно тогда, когда сумма прозрачностей равна единице.
Разъедешься по длительностям — на стыке появится провал в пустоту. Смещение
же ведём «выездной» кривой, и оно даёт ощущение аккуратности. */
::view-transition-old(root) {
animation: vtp-fade-out ${duration}s linear both, vtp-lift-out ${duration}s cubic-bezier(0.4, 0, 0.2, 1) both;
}
::view-transition-new(root) {
animation: vtp-fade-in ${duration}s linear both, vtp-lift-in ${duration}s cubic-bezier(0.16, 1, 0.3, 1) both;
}
/* Панель не должна мигать вместе со страницей — выносим её из общего снимка.
Но именованные элементы становятся ПЛОСКИМИ соседями: вложенность DOM в дереве
перехода не сохраняется, и порядок задаётся только z-index. Поэтому панель
разобрана на три слоя — подложка, «таблетка», подписи, — иначе таблетка
закрывает собой текст активного пункта (или подложка закрывает таблетку). */
.vtp-navbg { view-transition-name: vtp-navbg; }
.vtp-pill { view-transition-name: vtp-pill; }
.vtp-l0 { view-transition-name: vtp-l0; }
.vtp-l1 { view-transition-name: vtp-l1; }
.vtp-l2 { view-transition-name: vtp-l2; }
::view-transition-group(vtp-navbg) { z-index: 3; animation: none; }
::view-transition-group(vtp-l0),
::view-transition-group(vtp-l1),
::view-transition-group(vtp-l2) { z-index: 5; }
/* «таблетка» активного пункта — тот же элемент на новом месте, и браузер САМ
перевозит её между вкладками. Ноль строчек JS, ноль измерений */
::view-transition-group(vtp-pill) {
z-index: 4; animation-duration: ${duration + 0.04}s; animation-timing-function: cubic-bezier(0.2, 0, 0, 1);
}
@media (prefers-reduced-motion: reduce) {
::view-transition-old(root), ::view-transition-new(root),
::view-transition-group(vtp-pill) { animation: none; }
}
`}</style>
<div
style={{
position: "absolute",
inset: 0,
display: "grid",
placeItems: "center",
background: `radial-gradient(70% 80% at 50% 32%, ${cur.accent.replace(")", " / 0.28)")}, transparent 70%), oklch(0.15 0.02 270)`,
}}
>
<div style={{ textAlign: "center" }}>
<div style={{ fontSize: "2.2rem", fontWeight: 750, letterSpacing: "-0.03em" }}>{cur.title}</div>
<div style={{ marginTop: 6, fontFamily: "ui-monospace, monospace", fontSize: "0.75rem", opacity: 0.55 }}>
{cur.note} — переход рисует браузер, анимация в CSS
</div>
</div>
</div>
<nav
style={{
position: "absolute",
bottom: 18,
left: "50%",
transform: "translateX(-50%)",
display: "flex",
gap: 8,
padding: "8px 10px",
zIndex: 3,
}}
>
<span
className="vtp-navbg"
aria-hidden
style={{
position: "absolute",
inset: 0,
borderRadius: 999,
border: "1px solid oklch(0.32 0.02 270)",
background: "oklch(0.2 0.02 270 / 0.85)",
backdropFilter: "blur(10px)",
}}
/>
{PAGES.map((p, i) => (
<button
key={p.title}
onClick={() => go(i)}
aria-current={i === page}
style={{
position: "relative",
border: "none",
borderRadius: 999,
padding: "7px 14px",
fontSize: "0.8rem",
cursor: "pointer",
background: "transparent",
color: i === page ? "oklch(0.15 0.02 270)" : "oklch(0.85 0.01 265)",
fontWeight: 600,
transition: "color 0.3s ease",
}}
>
{i === page && (
<span
className="vtp-pill"
aria-hidden
style={{ position: "absolute", inset: 0, borderRadius: 999, background: p.accent }}
/>
)}
<span className={`vtp-l${i}`} style={{ position: "relative" }}>
{p.title}
</span>
</button>
))}
</nav>
</div>
);
}
Похожие эффекты
лицензия: MITавтор: Motivaисточник: оригинал (Motiva)v1.0.0