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

Stack Transition

Переход стопкой в духе iOS: текущая страница отъезжает вглубь и притухает, новая въезжает снизу отдельным листом с тенью.

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

Пропсы

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

ПропТипПо умолчаниюЧто это
duration?number0.62Длительность, сек (формат «0.62»).
depth?number0.9Насколько уменьшается уходящая страница (формат «0.9»): ближе к 1 — сдержаннее.
dim?number0.45Сила затемнения уходящей страницы, 0–1 (формат «0.45»).

Установка

npx shadcn@latest add https://motiva.pages.dev/r/stack-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";

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" },
];

function PageView({ page }: { page: (typeof PAGES)[number] }) {
  return (
    <div
      style={{
        position: "absolute",
        inset: 0,
        display: "grid",
        placeItems: "center",
        background: `radial-gradient(70% 80% at 50% 32%, ${page.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" }}>{page.title}</div>
        <div style={{ marginTop: 6, fontFamily: "ui-monospace, monospace", fontSize: "0.75rem", opacity: 0.55 }}>
          {page.note} — новая страница въезжает поверх стопкой
        </div>
      </div>
    </div>
  );
}

export interface StackTransitionProps {
  /** Длительность, сек (формат «0.62»). */
  duration?: number;
  /** Насколько уменьшается уходящая страница (формат «0.9»): ближе к 1 — сдержаннее. */
  depth?: number;
  /** Сила затемнения уходящей страницы, 0–1 (формат «0.45»). */
  dim?: number;
}

/**
 * Переход стопкой (паттерн iOS/Android): текущая страница отъезжает вглубь — уменьшается,
 * скругляется и притухает, — а новая въезжает снизу отдельным «листом» с тенью и ложится сверху.
 * Две страницы живут одновременно, поэтому переход читается как навигация, а не как смена контента.
 *
 * Затемнение нижней страницы сделано ЗАТЕНЯЮЩИМ СЛОЕМ, а не `filter: brightness()`.
 * Разница принципиальная: фильтр живёт на стадии отрисовки, и браузер каждый кадр заново
 * растрирует полноэкранный слой вместе с его градиентом. Слой с opacity уходит в композитор
 * целиком — та же картинка, но без единой перерисовки.
 *
 * Reduced-motion/data-shot — мгновенная смена.
 */
export function StackTransition({
  duration = 0.62,
  depth = 0.9,
  dim = 0.45,
}: StackTransitionProps = {}) {
  const [page, setPage] = useState(0);
  const [incoming, setIncoming] = useState<number | null>(null);
  const belowRef = useRef<HTMLDivElement>(null);
  const dimRef = useRef<HTMLDivElement>(null);
  const aboveRef = useRef<HTMLDivElement>(null);

  const frozen = () =>
    document.documentElement.hasAttribute("data-shot") ||
    window.matchMedia("(prefers-reduced-motion: reduce)").matches;

  const go = (next: number) => {
    if (next === page || incoming !== null) return;
    if (frozen()) {
      setPage(next);
      return;
    }
    setIncoming(next);
  };

  useLayoutEffect(() => {
    const above = aboveRef.current;
    const below = belowRef.current;
    const veil = dimRef.current;
    if (incoming === null || !above || !below || !veil) return;

    const tl = gsap.timeline({
      onComplete: () => {
        setPage(incoming);
        setIncoming(null);
        gsap.set([below, veil], { clearProps: "all" });
      },
    });
    tl.fromTo(below, { yPercent: 0, scale: 1 }, { yPercent: -8, scale: depth, duration, ease: "power3.inOut" }, 0)
      .fromTo(veil, { opacity: 0 }, { opacity: dim, duration, ease: "power2.out" }, 0)
      // радиус доводим твином, а не сменой класса: иначе на первом кадре, пока масштаб
      // ещё 1, у полноэкранной страницы разом появляются углы — и это видно
      .fromTo(below, { borderRadius: 0 }, { borderRadius: 20, duration: 0.3, ease: "power2.out" }, 0)
      .fromTo(above, { yPercent: 104 }, { yPercent: 0, duration: duration + 0.06, ease: "power3.inOut" }, 0.04);
    return () => {
      tl.kill();
    };
  }, [incoming, duration, depth, dim]);

  const cur = PAGES[page] ?? PAGES[0]!;
  const next = incoming !== null ? PAGES[incoming] : null;
  const moving = incoming !== null;

  return (
    <div
      style={{
        position: "absolute",
        inset: 0,
        overflow: "hidden",
        color: "oklch(0.92 0.01 265)",
        background: "oklch(0.09 0.015 270)",
      }}
    >
      <div
        ref={belowRef}
        style={{
          position: "absolute",
          inset: 0,
          overflow: "hidden",
          // will-change держим только на время перехода: постоянный слой в композиторе
          // это память, которую страница занимает вообще без причины
          willChange: moving ? "transform" : undefined,
        }}
      >
        <PageView page={cur} />
        <div ref={dimRef} style={{ position: "absolute", inset: 0, background: "oklch(0 0 0)", opacity: 0 }} />
      </div>

      {next && (
        <div
          ref={aboveRef}
          style={{
            position: "absolute",
            inset: 0,
            zIndex: 2,
            overflow: "hidden",
            willChange: "transform",
            boxShadow: "0 -30px 70px oklch(0 0 0 / 0.6)",
            borderRadius: 20,
          }}
        >
          <PageView page={next} />
        </div>
      )}

      <nav
        style={{
          position: "absolute",
          bottom: 18,
          left: "50%",
          transform: "translateX(-50%)",
          display: "flex",
          gap: 8,
          padding: "8px 10px",
          borderRadius: 999,
          border: "1px solid oklch(0.32 0.02 270)",
          background: "oklch(0.2 0.02 270 / 0.85)",
          backdropFilter: "blur(10px)",
          zIndex: 3,
        }}
      >
        {PAGES.map((p, i) => {
          // подсветку ведём по ЦЕЛИ перехода, а не по доехавшему состоянию: иначе кнопка
          // остаётся неактивной все 700 мс полёта и клик кажется непринятым
          const on = i === (incoming ?? page);
          return (
            <button
              key={p.title}
              onClick={() => go(i)}
              aria-current={on}
              style={{
                border: "none",
                borderRadius: 999,
                padding: "7px 14px",
                fontSize: "0.8rem",
                cursor: "pointer",
                background: on ? p.accent : "transparent",
                color: on ? "oklch(0.15 0.02 270)" : "oklch(0.85 0.01 265)",
                fontWeight: 600,
                transition: "background 0.3s ease, color 0.3s ease",
              }}
            >
              {p.title}
            </button>
          );
        })}
      </nav>
    </div>
  );
}
лицензия: MITавтор: Motivaисточник: оригинал (Motiva)v1.0.0