← каталог
Stack Transition
Переход стопкой в духе iOS: текущая страница отъезжает вглубь и притухает, новая въезжает снизу отдельным листом с тенью.
gsapclickrevealintermediate~70 КБreduced-motion ✓
🎛 Настройки — крути и копируй
Пропсы
Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.
| Проп | Тип | По умолчанию | Что это |
|---|---|---|---|
| duration? | number | 0.62 | Длительность, сек (формат «0.62»). |
| depth? | number | 0.9 | Насколько уменьшается уходящая страница (формат «0.9»): ближе к 1 — сдержаннее. |
| dim? | number | 0.45 | Сила затемнения уходящей страницы, 0–1 (формат «0.45»). |
Установка
npx shadcn@latest add https://motiva.pages.dev/r/stack-transition.jsonзависимости: gsap@^3.15.0
Что нужно в проекте до установки
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 { 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