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

View Transition (CSS)

Переход страниц на View Transitions API: анимация описана только в CSS (::view-transition-old/new), JS — один вызов. Для MPA хватает @view-transition.

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

Пропсы

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

ПропТипПо умолчаниюЧто это
duration?number0.4Длительность перехода, сек (формат «0.4»).
shift?number16Смещение приходящего кадра, px (формат «16»): 0 — только растворение, без движения.

Установка

npx shadcn@latest add https://motiva.pages.dev/r/view-transition-page.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 { 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