← каталог
Curtain Transition
Переход между страницами шторкой: полосы наезжают снизу stagger-ом, контент меняется под ними, полосы уходят вверх.
gsapclickrevealstaggerintermediate~23 КБreduced-motion ✓
🎛 Настройки — крути и копируй
Пропсы
Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.
| Проп | Тип | По умолчанию | Что это |
|---|---|---|---|
| stripes? | number | 4 | Число полос (формат «4»): больше полос — дробнее и быстрее «закрывается» кадр. |
| duration? | number | 0.45 | Длительность наезда, сек (формат «0.45»). |
| stagger? | number | 0.07 | Задержка между полосами, сек (формат «0.07»). |
Установка
npx shadcn@latest add https://motiva.pages.dev/r/curtain-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 { useEffect, 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" },
];
export interface CurtainTransitionProps {
/** Число полос (формат «4»): больше полос — дробнее и быстрее «закрывается» кадр. */
stripes?: number;
/** Длительность наезда, сек (формат «0.45»). */
duration?: number;
/** Задержка между полосами, сек (формат «0.07»). */
stagger?: number;
}
/**
* Переход между «страницами» шторкой: полосы наезжают снизу stagger-ом, контент меняется
* под шторкой, полосы уходят вверх. Reduced-motion/data-shot — мгновенная смена без шторки.
*/
export function CurtainTransition({
stripes = 4,
duration = 0.45,
stagger = 0.07,
}: CurtainTransitionProps = {}) {
const COLS = Array.from({ length: Math.max(2, Math.round(stripes)) }, (_, i) => i);
const [page, setPage] = useState(0);
const busy = useRef(false);
const tl = useRef<gsap.core.Timeline | null>(null);
const stripesRef = useRef<HTMLDivElement>(null);
// размонтирование посреди перехода: без этого твин доигрывает вхолостую и дёргает setState
useEffect(() => () => void tl.current?.kill(), []);
const frozen = () =>
document.documentElement.hasAttribute("data-shot") ||
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const go = (next: number) => {
if (next === page || busy.current) return;
if (frozen() || !stripesRef.current) {
setPage(next);
return;
}
busy.current = true;
const cols = stripesRef.current.children;
tl.current = gsap
.timeline({
onComplete: () => {
busy.current = false;
},
})
.set(cols, { transformOrigin: "50% 100%" })
.to(cols, { scaleY: 1, duration, ease: "power3.inOut", stagger })
.add(() => setPage(next))
.set(cols, { transformOrigin: "50% 0%" })
.to(cols, { scaleY: 0, duration: duration + 0.05, ease: "power3.inOut", stagger }, "+=0.1");
};
const cur = PAGES[page] ?? PAGES[0]!;
return (
<div style={{ position: "absolute", inset: 0, overflow: "hidden", color: "oklch(0.92 0.01 265)" }}>
<div
style={{
position: "absolute",
inset: 0,
display: "grid",
placeItems: "center",
background: `radial-gradient(70% 80% at 50% 30%, ${cur.accent.replace(")", " / 0.25)")}, 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} — жми пункты меню ↓
</div>
</div>
</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.8)",
backdropFilter: "blur(10px)",
zIndex: 3,
}}
>
{PAGES.map((p, i) => (
<button
key={p.title}
onClick={() => go(i)}
aria-current={i === page}
style={{
border: "none",
borderRadius: 999,
padding: "7px 14px",
fontSize: "0.8rem",
cursor: "pointer",
background: i === page ? p.accent : "transparent",
color: i === page ? "oklch(0.15 0.02 270)" : "oklch(0.85 0.01 265)",
fontWeight: 600,
}}
>
{p.title}
</button>
))}
</nav>
<div
ref={stripesRef}
aria-hidden
style={{
position: "absolute",
inset: 0,
display: "grid",
gridTemplateColumns: `repeat(${COLS.length}, 1fr)`,
zIndex: 2,
pointerEvents: "none",
}}
>
{COLS.map((i) => (
<div
key={i}
style={{
background: "oklch(0.22 0.03 285)",
transform: "scaleY(0)",
transformOrigin: "50% 100%",
marginInline: -0.5,
}}
/>
))}
</div>
</div>
);
}
import gsap from "gsap";
/**
* Curtain Transition — vanilla JS/GSAP (без React). Шторка из полос накрывает экран,
* в закрытом состоянии зовёт твой колбэк смены контента, затем открывается.
*
* <script type="module">
* import { CurtainTransition } from './curtain-transition.vanilla.js';
* const curtain = new CurtainTransition(document.body, { columns: 4 });
* link.addEventListener('click', () => curtain.go(() => showPage('projects')));
* </script>
*
* @example curtain.go(async () => { ...поменяй DOM/страницу... })
*/
export class CurtainTransition {
constructor(host = document.body, { columns = 4, color = "oklch(0.22 0.03 285)", zIndex = 9998 } = {}) {
this.busy = false;
this.wrap = document.createElement("div");
this.wrap.setAttribute("aria-hidden", "true");
this.wrap.style.cssText = `position:fixed;inset:0;display:grid;grid-template-columns:repeat(${columns},1fr);
z-index:${zIndex};pointer-events:none;`;
this.cols = Array.from({ length: columns }, () => {
const c = document.createElement("div");
c.style.cssText = `background:${color};transform:scaleY(0);transform-origin:50% 100%;margin-inline:-0.5px;`;
this.wrap.appendChild(c);
return c;
});
host.appendChild(this.wrap);
}
/** Закрыть шторку → выполнить swap → открыть. Возвращает Promise завершения. */
go(swap) {
if (this.busy) return Promise.resolve();
const frozen =
document.documentElement.hasAttribute("data-shot") ||
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (frozen) return Promise.resolve(swap?.());
this.busy = true;
return new Promise((resolve) => {
gsap
.timeline({
onComplete: () => {
this.busy = false;
resolve();
},
})
.set(this.cols, { transformOrigin: "50% 100%" })
.to(this.cols, { scaleY: 1, duration: 0.45, ease: "power3.inOut", stagger: 0.07 })
.add(() => swap?.())
.set(this.cols, { transformOrigin: "50% 0%" })
.to(this.cols, { scaleY: 0, duration: 0.5, ease: "power3.inOut", stagger: 0.07 }, "+=0.1");
});
}
destroy() {
this.wrap.remove();
}
}
Похожие эффекты
лицензия: MITавтор: Motivaисточник: оригинал (Motiva)v1.0.0